Guides · integration
Integrating Reading
Two paths to surface the Reading payload in your own product: polling (pull, simple) or webhooks (push, recommended). Both return the same shape. Pick based on your integration constraints.
Overview
Reading runs asynchronously after a simulation completes. Typical end-to-end latency is 30-60 seconds from sim completion to the Reading payload becoming available at
GET /v1/simulations/{id}/insightsPolling pattern
The existing status endpoint carries an additive
insights_status
Python
import time
import httpx
API = "https://api.simulix.com"
KEY = "sk_live_..."
def poll_until_reading_ready(sim_id: str, timeout: int = 180) -> dict | None:
"""Returns the Reading payload, or None on timeout."""
headers = {"Authorization": f"Bearer {KEY}"}
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
status = httpx.get(f"{API}/v1/simulations/{sim_id}", headers=headers).json()["data"]
if status["status"] != "completed":
time.sleep(2)
continue
if status.get("insights_status") in ("completed", "failed", "disabled"):
insights = httpx.get(
f"{API}/v1/simulations/{sim_id}/insights",
headers=headers,
).json()["data"]
return insights
# Sim done, Reading still running. Honor Retry-After when present.
time.sleep(5)
return None
TypeScript
const API = "https://api.simulix.com";
const KEY = process.env.SIMULIX_KEY!;
const HEADERS = { Authorization: `Bearer ${KEY}` };
async function pollUntilReadingReady(simId: string, timeoutMs = 180_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const statusRes = await fetch(`${API}/v1/simulations/${simId}`, { headers: HEADERS });
const { data: status } = await statusRes.json();
if (status.status !== "completed") {
await sleep(2_000);
continue;
}
if (["completed", "failed", "disabled"].includes(status.insights_status ?? "")) {
const r = await fetch(`${API}/v1/simulations/${simId}/insights`, { headers: HEADERS });
const { data } = await r.json();
return data;
}
await sleep(5_000);
}
return null;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Webhook pattern
Pass
callback_url
simulation.insights.completed
.failed
simulation.completed
Python — verifier + handler
import hashlib
import hmac
import json
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = b"<your webhook signing secret>"
MAX_AGE_SECONDS = 5 * 60
app = FastAPI()
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > MAX_AGE_SECONDS:
return False
expected = hmac.new(
SECRET, f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
@app.post("/webhooks/simulix")
async def webhook(req: Request):
raw = await req.body()
sig = req.headers.get("X-Simulix-Signature", "")
if not verify(raw, sig):
raise HTTPException(401, "bad signature")
event = json.loads(raw)
if event["type"] == "simulation.insights.completed":
sim_id = event["data"]["simulation_id"]
cost = event["data"]["cost_usd_estimate"]
# GET the full Reading payload, render in your UI.
...
return {"ok": True}
TypeScript — verifier + handler
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SIMULIX_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 5 * 60;
const app = express();
function verify(raw: Buffer, header: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const ts = Number(parts.t);
if (Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
app.post(
"/webhooks/simulix",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body, req.header("X-Simulix-Signature") ?? "")) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "simulation.insights.completed") {
const simId = event.data.simulation_id as string;
const cost = event.data.cost_usd_estimate as number;
// GET the full Reading payload, render in your UI.
}
res.json({ ok: true });
}
);
Selective rendering with block_status
Each of the eight blocks has its own lifecycle. Use
block_status
post_hoc
The pairing rule (per Section 13.6 of the spec):
- Non-null payload always pairs with .
block_status[name] === "completed"
- Null payload always pairs with status in .
{ skipped, failed, not_applicable } - A non-null payload with an empty list (e.g. ) is a valid result — the block ran and found nothing worth surfacing.
reservations: []
type BlockName = keyof typeof post_hoc;
function shouldRender(blockName: BlockName, blockStatus: Record<string, string>): boolean {
return blockStatus[blockName] === "completed";
}
// In your renderer:
{shouldRender("synthesis", insights.block_status) && (
<SynthesisCard data={insights.post_hoc.synthesis!} />
)}
{shouldRender("patterns", insights.block_status) &&
insights.post_hoc.patterns!.patterns.length > 0 && (
<PatternsList items={insights.post_hoc.patterns!.patterns} />
)}
// across_runs with status "not_applicable" → just don't render the card.
Calibration boundary
Reading is post-hoc enrichment, not part of the calibrated verdict. The response carries a
calibration_boundary
<section className="mt-8 border-t border-amber-400 pt-6">
<p className="text-xs uppercase tracking-wider text-zinc-500">Post-hoc</p>
<h2 className="text-xl font-semibold mt-1">Reading</h2>
{/* render the eight blocks here */}
<p className="mt-8 text-xs italic text-zinc-500">
{insights.calibration_boundary.disclaimer}
</p>
</section>
The Simulix dashboard renders the same payload with an amber hairline above the panel, a per-block
READING · post-hoc
Per-sim opt-out
Pass
generate_insights: false
POST /v1/simulations
/v1/simulations/auto
POST /v1/simulations
{
"workflow_id": "cdc_nhis_adult_smoking",
"agent_count": 50,
"generate_insights": false
}The sim runs normally; the simulation_insights row inserts directly at
status="disabled"
GET /insights
insights_status="disabled"
Change your mind later? POST to
/v1/simulations/{id}/insights/generateCost projection
Reading is metered at per-sim granularity via
cost_usd_estimate
Per-tier monthly ceilings:
| Tier | Monthly cap | Notes |
|---|---|---|
| Sandbox | $5 OR 100 sims | Whichever is more generous |
| Growth | $50 | ~1,000 sims with Reading |
| Enterprise | Uncapped | Per-contract overrides |
Estimating from historical volume
import httpx
API = "https://api.simulix.com"
KEY = "sk_live_..."
# Sum cost_usd_estimate across your last 30 days of sims.
def reading_cost_last_30_days() -> float:
headers = {"Authorization": f"Bearer {KEY}"}
total = 0.0
cursor = None
while True:
url = f"{API}/v1/simulations"
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
r = httpx.get(url, headers=headers, params=params).json()
for sim in r["data"]:
ins = httpx.get(
f"{API}/v1/simulations/{sim['id']}/insights", headers=headers
).json()["data"]
if ins.get("cost_usd_estimate"):
total += ins["cost_usd_estimate"]
cursor = r["meta"].get("next_cursor")
if not r["meta"].get("has_more"):
break
return total
For at-scale forecasting use the dashboard's billing surface instead — the per-sim loop above is fine for ad-hoc estimates but burns request quota on large volumes.
Bulk fetch (v1.0.8)
A bulk endpoint
GET /v1/insights?simulation_ids=sim_01,sim_02,...
Next
- Reading API reference — full field reference + error codes.
- Post-hoc Reading concept — what Reading is, what it isn't, stability contract.