Overview
The Simulation API generates a complete synthetic respondent-level dataset. You define who the respondents are (quota groups — target-group cells with explicit counts) and what they answer (a full survey), and the Oracle generates a row per respondent with their demographics and their individual answer to every question. The answer data is returned to you directly — there are no redirect links or external survey tools to reconcile.
Because a run can involve thousands of respondents across up to 30 questions, simulation is an asynchronous job. The lifecycle is:
- Create — submit the survey and quota groups; receive a job
id. - Track — poll the job, or receive a webhook when it finishes.
- Retrieve — pull the respondent-level results.
Base URL: https://app.simsurveys.com/api/v1
Authentication
All requests are authenticated with a Bearer token. Create and manage API keys from the API Dashboard.
Authorization: Bearer sk_live_abc123...
- Keys are 72 characters long and begin with
sk_live_. - Keys are shown once at creation and cannot be retrieved again.
- The key must carry the
oracle:simulatescope — select it when creating the key in the dashboard (or contact us to enable Oracle access). A missing, invalid, or expired key returns401; a key that lacks the scope returns403. - Keep keys server-side. An
sk_live_key is a sensitive credential — never embed it in browser or mobile-client code. The API is called server-to-server and does not support cross-origin (CORS) browser requests. - There is no sandbox or test mode — every simulation is billable. Validate integrations against small jobs.
Rate Limiting
Each API key has a configurable rate limit (default 60 requests per minute, range 1–1,000). Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers (X-RateLimit-Reset is a Unix epoch-seconds timestamp). Creating a simulation counts as a single request, regardless of respondent count.
When the limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header (in seconds) and the standard typed error envelope:
{ "error": { "type": "rate_limit_error", "message": "Rate limit exceeded. Try again later." } }
Job Lifecycle
A simulation job moves through the following statuses:
| Status | Meaning |
|---|---|
queued | Accepted and waiting to start |
processing | Respondents are being generated |
completed | Finished — results are available |
failed | The job could not complete; see the error object |
Jobs run to completion — there is no cancel operation once a job is created. Simulation is atomic: if any quota group fails to generate, the entire job transitions to failed and no partial results are returned. Once completed, a job's results are retained indefinitely and remain retrievable.
Create a Simulation
Creates a simulation job and returns immediately with a job id and status: "queued".
Idempotency: because a job is billable, pass an Idempotency-Key header (any unique string) to make retries safe. A replay with the same key returns 200 with the original job at its current status, not a duplicate. Reusing a key with a different request body returns 409 with a conflict_error. A key is bound only once a job is created (2xx) — a request that fails validation does not consume it, so you can retry with a corrected body. Keys are honored for 24 hours.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
model_type |
string | Yes | One of: consumer, hcp, social, patient |
quota_groups |
array | Yes | One or more demographic cells, each with an explicit respondent count. Total sample size N = the sum of all counts (up to 5,000) |
survey |
object | Yes | The survey instrument. Contains questions (1–30 items) |
context |
string | No | Study context applied to the whole run. Consumer and patient models only. |
webhook_url |
string | No | HTTPS URL notified when the job reaches completed or failed. See Webhooks. If omitted, poll for status instead. |
Quota Group Object (quota_groups[])
| Field | Type | Required | Description |
|---|---|---|---|
count |
integer | Yes | Number of respondents to generate for this cell (≥ 1) |
demographics |
object | Yes* | The demographic profile for this cell (free-form key-value pairs; see the Demographics reference). Consumer, social, and patient models. |
medical_specialty |
string | Yes* | The cell's specialty — one of the 20 supported specialties. HCP model only (replaces demographics). |
* Each group carries the targeting fields appropriate to the chosen model_type — demographics for consumer/social/patient, medical_specialty for HCP.
Patient model — conditioning on a diagnosis: for the patient model, include a demographics key named exactly "Patient Condition" (title case, with a space) in a quota group to ground that cell in a specific condition, e.g. { "count": 100, "demographics": { "age": "55-64", "Patient Condition": "Type 2 Diabetes" } }. This key is case- and spelling-sensitive: because demographics are free-form, a mistyped key (e.g. patient_condition) is silently treated as an ordinary demographic label rather than a diagnosis, with no error.
Question Object (survey.questions[])
| Field | Type | Required | Applies to | Description |
|---|---|---|---|---|
id | string | Yes | all | Client-defined identifier, unique within the survey; used as the key for this question's answer in each respondent's responses. Duplicate ids are rejected with a validation_error. |
type | string | Yes | all | One of: single_select, multi_select, grid, numeric, ranking, text |
text | string | Yes | all | The question wording (max 5,000 characters) |
options | string[] | Yes | single_select, multi_select, ranking, numeric | 2–20 answer options. For numeric, each option is a numeric value or range treated as a single choice (e.g. "0", "1-2", "24"). |
rows | string[] | Yes | grid | The row items being rated (2–20) |
columns | string[] | Yes | grid | The scale/columns each row is rated against (2–20) |
max_selections | integer | No | multi_select | Maximum options a respondent may select. Defaults to the number of options (no cap). Respondents always select at least one. |
Example Request
curl -X POST https://app.simsurveys.com/api/v1/oracle/simulate \ -H "Authorization: Bearer sk_live_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7f3a9c1e-2b4d-4a10-9c8e-1f2a3b4c5d6e" \ -d '{ "model_type": "consumer", "quota_groups": [ { "count": 150, "demographics": { "age": "25-34", "gender": "Female" } }, { "count": 150, "demographics": { "age": "35-44", "gender": "Male" } } ], "survey": { "questions": [ { "id": "q1", "type": "single_select", "text": "Which brand do you prefer?", "options": ["Nike", "Adidas", "New Balance", "Puma"] }, { "id": "q2", "type": "multi_select", "text": "What matters when buying?", "options": ["Price", "Comfort", "Style", "Brand"], "max_selections": 2 }, { "id": "q3", "type": "grid", "text": "Rate each factor.", "rows": ["Durability", "Style"], "columns": ["Not important", "Somewhat", "Very important"] }, { "id": "q4", "type": "numeric", "text": "Pairs bought last year?", "options": ["0", "1-2", "3-5", "6+"] }, { "id": "q5", "type": "ranking", "text": "Rank these brands.", "options": ["Nike", "Adidas", "New Balance", "Puma"] }, { "id": "q6", "type": "text", "text": "Describe your last purchase." } ] } }'
Response (202 Accepted)
{ "id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "object": "oracle.simulation", "status": "queued", "model": "consumer", "respondent_count": 300, "created": 1770940800 }
Retrieve a Simulation
Returns the job's current status. Poll this endpoint until status is completed or failed; on completed, read respondents. Large datasets are paginated.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 100 | Respondents per page (1–1,000) |
offset | integer | 0 | Number of respondents to skip |
Response (while queued or processing)
Before the job finishes, the response carries status metadata only. The respondents and pagination fields are omitted until status is completed:
{ "id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "object": "oracle.simulation", "status": "processing", "model": "consumer", "respondent_count": 300, "created": 1770940800 }
Response (completed)
{ "id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "object": "oracle.simulation", "status": "completed", "model": "consumer", "respondent_count": 300, "created": 1770940800, "completed": 1770940815, "respondents": [ { "respondent_id": "resp_00001", "demographics": { "age": "25-34", "gender": "Female" }, "responses": { "q1": "Nike", "q2": ["Comfort", "Price"], "q3": { "Durability": "Very important", "Style": "Somewhat" }, "q4": "1-2", "q5": ["Nike", "New Balance", "Adidas", "Puma"], "q6": "I bought running shoes online during a sale." } } ], "pagination": { "limit": 100, "offset": 0, "total": 300 } }
- The example shows one respondent for brevity; a page returns up to
limitrows. respondentsandpaginationare present only whenstatusiscompleted.respondent_countis the requested sample size (the sum of quota-group counts). Because generation is atomic, on a completed job it always equalspagination.total. Page againstpagination.total; whenoffset ≥ total,respondentsis an empty array.- Each response is keyed by the question
id. Themodelfield echoes the request'smodel_type. createdandcompletedare Unix timestamps in seconds.- Completed results are retained indefinitely and can be re-fetched at any time.
Response for HCP and patient jobs
For the hcp model, each respondent row carries medical_specialty in place of demographics. The patient model uses demographics, as above; the input demographic keys are echoed back on each row, including the special "Patient Condition" key when supplied.
{ "respondent_id": "resp_00001", "medical_specialty": "Cardiology & Cardiovascular", "responses": { "q1": "Drug A" } }
List Simulations
Lists your simulation jobs, most recent first. Because results are retained indefinitely and there is no other lookup, this is how you recover a job id that wasn't persisted. Paginated; optionally filter by status.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter by status: queued, processing, completed, or failed |
limit | integer | 20 | Jobs per page (1–100) |
offset | integer | 0 | Number of jobs to skip |
Response
{ "object": "list", "data": [ { "id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "object": "oracle.simulation", "status": "completed", "model": "consumer", "respondent_count": 300, "created": 1770940800 } ], "pagination": { "limit": 20, "offset": 0, "total": 42 } }
List items are job summaries (no respondents); fetch full results from the retrieve endpoint. When offset ≥ total, data is an empty array. Every request — create, retrieve, and list — counts against your key's rate limit.
Webhooks
If you supply an HTTPS webhook_url when creating a job, the Oracle sends a POST to that URL when the job reaches a terminal status — so you don't have to poll. The payload identifies the job and its outcome; always fetch the data itself from the retrieve endpoint.
POST // to your webhook_url { "id": "evt_9f8e7d6c5b4a3210", "object": "oracle.simulation.event", "type": "simulation.completed", "simulation_id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "status": "completed", "respondent_count": 300, "occurred_at": 1770940815 }
The type is simulation.completed or simulation.failed, matching the job status. occurred_at is the Unix-seconds time the event was emitted (distinct from the job's own created).
Verifying signatures
Every delivery is signed so you can confirm it genuinely came from Simsurveys. Two headers are sent:
| Header | Value |
|---|---|
X-Simsurveys-Timestamp | Unix seconds when the request was sent |
X-Simsurveys-Signature | Hex HMAC-SHA256 of {timestamp}.{raw_body}, keyed with your webhook signing secret |
The signing secret is a single account-level secret, found in your dashboard (distinct from your sk_live_ API key). To verify: recompute the lowercase-hex HMAC over timestamp + "." + raw_request_body using that secret, and compare with a constant-time equality against the (lowercase) header value. Reject the request if it doesn't match, or if the timestamp is outside a 5-minute window (replay protection).
import hmac, hashlib, time def verify(raw_body, timestamp, signature, secret): if abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature.lower())
Treat the webhook as a hint, not the source of truth. After verifying, re-GET the job to read the authoritative status and results.
A simulation.failed event carries the same fields with status: "failed":
{ "id": "evt_1a2b3c4d5e6f7890", "object": "oracle.simulation.event", "type": "simulation.failed", "simulation_id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "status": "failed", "respondent_count": 300, "occurred_at": 1770940815 }
Delivery & retries
- Respond with any
2xxto acknowledge. Non-2xxor timeout (>10s) triggers retries with exponential backoff for up to 24 hours. - Delivery is best-effort: after 24 hours of failed retries the event is dropped. Always keep polling available as the source of truth.
- Delivery is at-least-once — the same event may arrive more than once. Deduplicate on the event
id. - Event payloads follow the same versioning contract as API responses — ignore unrecognized fields.
Answer Encoding by Question Type
Each respondent's responses object maps a question id to its answer. The answer's shape depends on the question type:
| Type | Value shape | Example |
|---|---|---|
single_select | string — the chosen option | "Nike" |
multi_select | string[] — the chosen options | ["Comfort", "Price"] |
grid | object — { row: column } per row | { "Durability": "Very important", "Style": "Somewhat" } |
numeric | string — the selected numeric value/range | "1-2" |
ranking | string[] — options ordered best → worst | ["Nike", "New Balance", "Adidas", "Puma"] |
text | string — open-ended response | "I shop online during sales." |
single_selectreturns exactly one of the question'soptions.multi_selectreturns between 1 andmax_selectionsoptions (never empty).rankingis exhaustive — it returns all of the question'soptions, in ranked order.numericreturns one of the question'soptions(a numeric value or range, e.g."1-2"), as a string.gridreturns everyrowsitem as a key, each mapped to exactly onecolumnsvalue.textreturns a free-form string.
Code Examples
A full run: create the job, poll until it completes, then page through the respondent-level results.
Python
import requests, time API_KEY = "sk_live_your_key_here" BASE_URL = "https://app.simsurveys.com/api/v1" HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} # 1. Create the simulation job = requests.post( f"{BASE_URL}/oracle/simulate", headers=HEADERS, json={ "model_type": "consumer", "quota_groups": [ {"count": 150, "demographics": {"age": "25-34", "gender": "Female"}}, {"count": 150, "demographics": {"age": "35-44", "gender": "Male"}}, ], "survey": {"questions": [ {"id": "q1", "type": "single_select", "text": "Which brand do you prefer?", "options": ["Nike", "Adidas", "New Balance", "Puma"]}, ]}, }, ).json() sim_id = job["id"] # 2. Poll until complete while job["status"] in ("queued", "processing"): time.sleep(2) job = requests.get(f"{BASE_URL}/oracle/simulate/{sim_id}", headers=HEADERS).json() if job["status"] == "failed": raise RuntimeError(job["error"]["message"]) # 3. Page through respondent-level results (pagination.total is authoritative) respondents, offset = [], 0 while True: page = requests.get( f"{BASE_URL}/oracle/simulate/{sim_id}", headers=HEADERS, params={"limit": 1000, "offset": offset}, ).json() respondents += page["respondents"] offset += len(page["respondents"]) if offset >= page["pagination"]["total"]: break print(f"Retrieved {len(respondents)} respondents")
JavaScript / Node.js
const API_KEY = "sk_live_your_key_here"; const BASE_URL = "https://app.simsurveys.com/api/v1"; const HEADERS = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 1. Create the simulation let job = await (await fetch(`${BASE_URL}/oracle/simulate`, { method: "POST", headers: HEADERS, body: JSON.stringify({ model_type: "consumer", quota_groups: [ { count: 150, demographics: { age: "25-34", gender: "Female" } }, { count: 150, demographics: { age: "35-44", gender: "Male" } }, ], survey: { questions: [ { id: "q1", type: "single_select", text: "Which brand do you prefer?", options: ["Nike", "Adidas", "New Balance", "Puma"] }, ] }, }), })).json(); const simId = job.id; // 2. Poll until complete while (["queued", "processing"].includes(job.status)) { await sleep(2000); job = await (await fetch(`${BASE_URL}/oracle/simulate/${simId}`, { headers: HEADERS })).json(); } if (job.status === "failed") throw new Error(job.error.message); // 3. Page through respondent-level results (pagination.total is authoritative) const respondents = []; let offset = 0, total = Infinity; while (offset < total) { const page = await (await fetch( `${BASE_URL}/oracle/simulate/${simId}?limit=1000&offset=${offset}`, { headers: HEADERS }, )).json(); respondents.push(...page.respondents); offset += page.respondents.length; total = page.pagination.total; } console.log(`Retrieved ${respondents.length} respondents`);
Errors
Every error response — at any status code — uses the same typed error envelope. Request-time errors (bad body, invalid quotas, too many questions) are returned synchronously from the POST:
{ "error": { "type": "validation_error", "message": "survey.questions must contain between 1 and 30 items." } }
| Type | HTTP Status | Description |
|---|---|---|
validation_error | 400 | Invalid request body, quota groups, or survey |
authentication_error | 401 | Missing, invalid, or expired API key |
permission_error | 403 | Key lacks the oracle:simulate scope |
not_found_error | 404 | Unknown simulation id |
conflict_error | 409 | Idempotency-Key reused with a different request body |
rate_limit_error | 429 | Rate limit exceeded |
internal_error | 500 | Unexpected server error |
A job that fails after being accepted returns status: "failed" with an error object on the GET response. This is a job-level error: the HTTP status is still 200 (the request succeeded; the job did not). It carries the standard job fields plus error, and the error.type is a job-level value (currently generation_error) — a separate, open set from the transport error types above, so handle it independently of HTTP status. respondent_count stays the requested count on a failed job (no rows are produced).
{ "id": "sim_a1b2c3d4e5f6a1b2c3d4e5f6", "object": "oracle.simulation", "status": "failed", "model": "consumer", "respondent_count": 300, "created": 1770940800, "error": { "type": "generation_error", "message": "A quota group failed to generate; the job was aborted." } }
HTTP Status Codes
| Status | Meaning |
|---|---|
202 | Accepted — simulation job created |
200 | Success — job status/results returned |
400 | Bad request — invalid body, quotas, or survey |
401 | Unauthorized — missing, invalid, or expired API key |
403 | Forbidden — key lacks the oracle:simulate scope |
404 | Not found — unknown simulation id |
409 | Conflict — Idempotency-Key reused with a different request body |
429 | Too many requests — rate limit exceeded |
500 | Internal server error |
Limits & Constraints
| Constraint | Value |
|---|---|
| Questions per survey | 1–30 |
| Answer options per question | 2–20 |
| Max question length | 5,000 characters |
| Respondents per job | Up to 5,000 (sum of all quota-group counts) |
| Quota groups per job | At least 1; each count ≥ 1; combined total ≤ 5,000 |
| Grid rows / columns | 2–20 each |
| Results pagination | 100 per page (max 1,000) |
| Max request body size | 1 MB |
| Result retention | Indefinite — completed results never expire |
| Idempotency-Key window | 24 hours |
| Rate limit | Job creation counts as one request against your key's limit |
Exceeding any of these limits returns a 400 validation_error describing the violated constraint.
Demographics Reference
A quota group's demographics object (consumer, social, and patient models) is free-form: each key-value pair is passed through to the model verbatim. The API validates only that keys and values are strings — it does not enforce a fixed set. The table below is the recommended vocabulary the models were tuned on; using these keys and values gives the most reliable results.
| Key | Recommended values |
|---|---|
gender | Male, Female |
age | 18-24, 25-34, 35-44, 45-54, 55-64, 65-74, 75+ |
income | Under $10k, $10k-15k, $15k-20k, $20k-25k, $25k-30k, $30k-35k, $35k-40k, $40k-45k, $45k-50k, $50k-60k, $60k-75k, $75k-100k, $100k-125k, $125k-150k, $150k-200k, $200k+ |
location | Urban, Suburban, Rural |
race_ethnicity | White, Black/African American, American Indian/Alaska Native, Asian, Native Hawaiian/Pacific Islander, Other, Multiracial, Hispanic/Latino |
education | Some High School or Less, High School Graduate, Some College, College Graduate, Post-Graduate Degree |
employment | Not in Labor Force, Employed, Unemployed |
children | No, Yes |
political | Democrat, Republican, Libertarian, Green Party, Other |
marital_status | Married, Not Married |
For the patient model only, the special key "Patient Condition" conditions the cell on a diagnosis (see Create).
Supported Specialties
For the hcp model, a quota group's medical_specialty must exactly match one of these 20 values:
Allied Health & Other Medical Professionals | Neurology & Neurosurgery |
Anesthesiology & Pain Management | Nursing & Advanced Practice Providers |
Cardiology & Cardiovascular | Oncology & Hematology |
Dermatology & Skin Conditions | Pathology & Laboratory Medicine |
Emergency & Critical Care | Pediatrics & Child Health |
Endocrinology & Diabetes | Primary Care & General Practice |
Eye & Vision Care | Pulmonology & Respiratory Medicine |
Gastroenterology & Digestive Health | Radiology & Medical Imaging |
Internal Medicine Subspecialties | Surgical Specialties |
Mental Health & Psychiatry | Women's Health & Reproductive Medicine |
Versioning
The current API version is v1, reflected in the URL path (/api/v1). Backwards-incompatible changes ship under a new version prefix; additive changes (new optional request fields, new response fields) may appear within v1, so clients should ignore unrecognized response fields. Unrecognized request fields — and fields that are recognized but not applicable to the chosen model_type (e.g. context or demographics sent to the hcp model, or medical_specialty sent to a non-HCP model) — are rejected with a validation_error.