Simulix

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}/insights
. The endpoint reference at /docs/api-reference/insights covers shape; this guide covers integration mechanics.

Polling pattern

The existing status endpoint carries an additive

insights_status
field. Poll the status endpoint as you already do for the sim itself; branch on the new field to fetch the Reading payload when it's ready. One poll loop covers both lifecycles.

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
at sim-create time and the delivery worker fires
simulation.insights.completed
(or
.failed
) when the Reading row finalizes. Same HMAC signature scheme as
simulation.completed
; reject signatures older than 5 minutes.

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
to decide what to render; use the payload shape under
post_hoc
for content.

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.
    reservations: []
    ) is a valid result — the block ran and found nothing worth surfacing.
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
object so you can render the disclaimer string verbatim in your own UI. The string itself is API-stable; the rendering treatment is yours.

<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
eyebrow, and the disclaimer below. Match the visual separation you already use for any editorial vs measured content.

Per-sim opt-out

Pass

generate_insights: false
on
POST /v1/simulations
(or
/v1/simulations/auto
) to skip Reading for that specific simulation. Useful for CI smoke tests against your integration, sandbox previews, or any sim where the calibrated verdict alone is enough.

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"
. No webhook fires.
GET /insights
returns 200 with
insights_status="disabled"
.

Change your mind later? POST to

/v1/simulations/{id}/insights/generate
to opt in retroactively.

Cost projection

Reading is metered at per-sim granularity via

cost_usd_estimate
on every Reading response. Mean cost per sim: ~$0.05 (varies with transcript length; hard-capped at $0.10).

Per-tier monthly ceilings:

TierMonthly capNotes
Sandbox$5 OR 100 simsWhichever is more generous
Growth$50~1,000 sims with Reading
EnterpriseUncappedPer-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,...
ships in v1.0.8 to reduce N x HTTP overhead for integrators pulling many sims' Reading payloads at once. In v1.0.7b, parallel single-sim GETs are rate-limit-safe up to your tier's per-minute ceiling (1k Growth, 5k Enterprise).

Next