# EZPsych API Reference (v1) This document is written for AI agents (and the humans supervising them) who want to build and run an EZPsych experiment entirely through the API, without touching the web dashboard. If you are an agent: start by reading [`/docs/agent`](/docs/agent) for an overview, then [`workbook_creation_prompt.md`](/docs/agent/workbook_creation_prompt.md) and [`experiment_json_spec.md`](/docs/agent/experiment_json_spec.md) to learn the shape of an experiment spec. This page documents the HTTP contract for submitting and managing runs. --- ## 1. Authentication All endpoints except `POST /api/v1/experiments/validate` require a Bearer API key: ``` Authorization: Bearer ezp_ ``` **How to get a key:** API keys belong to a human account, not to an agent. Ask your human operator to: 1. Register an EZPsych account and verify their email at `https://ezpsy.ai/register`. 2. Sign in and create a key at `https://ezpsy.ai/settings/api-keys`. 3. Give you the key (`ezp_...`) as a secret — treat it like a password, never log it or send it to a third party. Keys act on behalf of the owning account: usage spends that account's credits and runs are listed/managed under that account. Missing or invalid keys return `401 Unauthorized`: ```json { "error": "unauthorized", "message": "Missing or invalid API key." } ``` --- ## 2. Pricing & credits - EZPsych runs on **prepaid credits**: `1 USD = 10,000 credits`. - `POST /api/v1/experiments/validate` is **free** and makes no LLM calls — iterate on your spec until it is valid at no cost. - `POST /api/v1/experiments` returns an `estimated_credits` figure *before* any credits are spent. The estimate includes a 2x service markup and a safety margin. - Once running, credits are deducted in real time based on **actual LLM usage x 2** (the same service markup applied up front). Any unused frozen credits are released back to the account when the run finishes, is stopped, or fails. - See `GET /api/v1/me` for the current balance and [`/pricing`](/pricing) for full pricing details. ### Respondent-call options that change the price Three `experiment_config` keys change what each simulated respondent call costs. All three are opt-in and default to the cheap setting, so a spec that omits them prices exactly as before. | Key | Type | Range | Default | Cost impact | |-----|------|-------|---------|-------------| | `temperature` | number | `0.0`–`2.0` | `0.2` | None. | | `enable_thinking` | boolean | `true` / `false` | `false` | **≈ x8 per-call cost** (reasoning tokens are billed as output tokens). | | `enable_websearch` | boolean | `true` / `false` | `false` | **≈ x40 per-call cost** — a flat **$0.02 per respondent call**, charged per request rather than per token. | They apply to the main flow and to question-order monitoring; persona-narrative and text-scoring calls are unaffected. Turning both on multiplies the two effects together. Estimates for these features are deliberately conservative — the extra frozen credits are released when the run ends. The `estimate` object breaks the surcharges out under `feature_costs` so you can show your human operator what the options are adding: ```json "feature_costs": { "temperature": 0.2, "enable_thinking": true, "thinking_multiplier": 8, "thinking_extra_usd": 0.3326, "priced_thinking_extra_usd": 0.7983, "enable_websearch": true, "websearch_per_call_usd": 0.02, "websearch_calls": 360, "websearch_usd": 7.2, "priced_websearch_usd": 17.28 } ``` `*_usd` fields are raw provider cost; `priced_*_usd` fields are the same amounts after markup and safety margin, i.e. their share of `estimated_price_usd`. --- ## 3. Endpoints ### `POST /api/v1/experiments/validate` Public. No auth required. Rate-limited (see [Rate limits](#6-rate-limits)) and size-capped (see [Size limits](#7-size-limits)). Validate an experiment spec before spending anything. Call this repeatedly while you fix issues — it is free and safe to call as often as the rate limit allows. **Request body** — a JSON experiment spec, documented in [`experiment_json_spec.md`](/docs/agent/experiment_json_spec.md): ```json { "questionnaire": [ ... ], "codebook": [ ... ], "personas": { ... }, "experiment_config": { "sample_size": 20, "repeats": 1 } } ``` Note: `experiment_config.model` is accepted but **ignored** on this JSON API path — it always runs the platform default model. Model selection is only available through the Excel-workbook upload path; see [`experiment_json_spec.md`](/docs/agent/experiment_json_spec.md#ignored-keys-warning) for details. **Response** — `200 OK` whether or not the spec is valid; check the `valid` field: ```json { "valid": false, "errors": [ { "path": "codebook[3].response_value", "message": "response_value 'F' does not match any option_id for question_id 'EXT2'." }, { "path": "experiment_config.sample_size", "message": "sample_size must be a positive integer." } ], "warnings": [ { "path": "codebook", "message": "Dimension 'Resilience' has only 1 scored item — Cronbach's alpha requires at least 2." } ], "estimate": null } ``` When `valid` is `true`, `estimate` is populated with the same cost-estimate shape returned by `POST /api/v1/experiments` (see below), so you can show your human operator the cost before they hand you a key to submit: ```json { "valid": true, "errors": [], "warnings": [], "estimate": { "estimated_credits": 4200, "estimated_price_usd": 0.42, "model": "google/gemini-3.1-flash-lite-preview", "sample_size": 20, "total_calls": 180 } } ``` ### `POST /api/v1/experiments` Auth: Bearer. Rate-limited and size-capped (see [Size limits](#7-size-limits)). Submit a **validated** spec and start the run. This spends the owner's credits (after freezing an estimate up front — see [Pricing & credits](#2-pricing--credits)). **Request body:** the same experiment spec shape accepted by `validate`. **Response — `202 Accepted`:** ```json { "job_id": "20260727T093000Z-a1b2c3d4", "status": "queued", "estimated_credits": 4200 } ``` **Error responses:** | Status | Body | Meaning | |---|---|---| | `400` | `{"error": "invalid_spec", "errors": [...]}` | Spec failed validation — call `validate` first and fix all `errors`. | | `402` | `{"error": "insufficient_credits", "needed": 4200, "have": 1000}` | Owner does not have enough credits. | | `402` | `{"error": "monthly_cap_exceeded", "monthly_cap_credits": 50000, "used_this_month": 48000, "needed": 4200}` | This key's monthly cap would be exceeded. | | `503` | `{"error": "platform_maintenance", "message": "..."}` | Upstream LLM provider balance too low; retry later. | ### `GET /api/v1/me` Auth: Bearer. ```json { "username": "researcher_jane", "credits": 18500 } ``` ### `GET /api/v1/experiments` Auth: Bearer. List all runs owned by the key's account, most recent first. Optional query params: `q` (search by filename/job_id), `status` (filter by status). ```json { "experiments": [ { "job_id": "20260727T093000Z-a1b2c3d4", "status": "running", "phase": "main_flow", "created_at": "2026-07-27T09:30:00+00:00", "overall_progress": { "percent": 42.0 } } ] } ``` ### `GET /api/v1/experiments/` Auth: Bearer. Full status for one run, including phase progress. ```json { "job_id": "20260727T093000Z-a1b2c3d4", "status": "running", "phase": "main_flow", "message": "Main flow is running.", "main_flow": { "status": "running", "completed": 76, "total": 180, "percent": 42.2 }, "question_order_monitoring": { "enabled": false, "status": "skipped", "percent": 100.0 }, "overall_progress": { "percent": 42.0 }, "download_ready": false } ``` `phase` progresses through: `queued` -> `validating` -> `main_flow` -> `saving_main_outputs` -> `question_order_monitoring` (if enabled) -> `packaging` -> `completed` (or `failed` / `stopped` / `paused` at any point). ### `GET /api/v1/experiments//result` Auth: Bearer. Returns `404` if the job has no results yet, `409` if it exists but isn't finished. On success, streams the result ZIP (`application/zip`) containing: - Raw per-response records (CSV) - `summary_scores.csv` — per-persona, per-dimension scores - Persona definitions - An HTML/PDF psychometric report (reliability, item analysis, order-effect results if question-order monitoring was enabled) ### `POST /api/v1/experiments//pause` Auth: Bearer. Only valid while `status == "running"`. Checkpoints progress so the run can be resumed later; unused credits stay frozen against this job. ```json { "pausing": true, "job_id": "20260727T093000Z-a1b2c3d4" } ``` ### `POST /api/v1/experiments//stop` Auth: Bearer. Only valid while `status` is `running`, `pausing`, or `paused`. Cannot be resumed — unused (frozen) credits are released back to the account. ```json { "stopping": true, "job_id": "20260727T093000Z-a1b2c3d4" } ``` ### `POST /api/v1/experiments//resume` Auth: Bearer. Only valid while `status == "paused"`. Requires the account to still have (or have frozen) enough credits to continue. ```json { "resumed": true, "job_id": "20260727T093000Z-a1b2c3d4" } ``` --- ## 4. Full walkthrough (curl) ```bash # 0. Your human operator gives you a key as an environment variable. export EZP_KEY="ezp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 1. Validate your draft spec. No auth, no cost — iterate until "valid": true. curl -s -X POST https://ezpsy.ai/api/v1/experiments/validate \ -H "Content-Type: application/json" \ --data @experiment.json | tee validate_result.json # -> {"valid": false, "errors": [{"path": "...", "message": "..."}], ...} # Fix experiment.json based on each error's "path"/"message", then re-run step 1. # 2. Once "valid": true, review "estimate" with your human operator, then submit. curl -s -X POST https://ezpsy.ai/api/v1/experiments \ -H "Authorization: Bearer $EZP_KEY" \ -H "Content-Type: application/json" \ --data @experiment.json | tee submit_result.json # -> {"job_id": "20260727T093000Z-a1b2c3d4", "status": "queued", "estimated_credits": 4200} JOB_ID=$(python3 -c "import json;print(json.load(open('submit_result.json'))['job_id'])") # 3. Poll status until phase reaches "completed" (or "failed"). while true; do STATUS=$(curl -s https://ezpsy.ai/api/v1/experiments/$JOB_ID \ -H "Authorization: Bearer $EZP_KEY" | python3 -c "import json,sys;print(json.load(sys.stdin)['status'])") echo "status=$STATUS" [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ] && break sleep 5 done # 4. Download the result archive. curl -s -L -o results.zip https://ezpsy.ai/api/v1/experiments/$JOB_ID/result \ -H "Authorization: Bearer $EZP_KEY" ``` --- ## 5. Error semantics All error responses are JSON with at least an `error` machine-readable code and a human-readable `message`: ```json { "error": "insufficient_credits", "message": "Insufficient credits. Need 4200, have 1000." } ``` Common codes: | HTTP status | `error` | Meaning | |---|---|---| | `400` | `invalid_spec` | Spec has validation errors — see `errors[]`, or re-run `validate`. | | `401` | `unauthorized` | Missing/invalid/revoked API key. | | `402` | `insufficient_credits` | Not enough credits; see `needed`/`have`. | | `402` | `monthly_cap_exceeded` | The API key's monthly cap would be exceeded. The cap counts the estimated credits of every run the key started in the current calendar month (UTC); it resets at the start of the next month. | | `404` | `not_found` | Unknown `job_id`, or it does not belong to this key's account. | | `409` | `conflict` | Action not valid for the run's current status (e.g. pausing a queued run). | | `413` | `spec_too_large` | Request body exceeds the size cap — see [Size limits](#7-size-limits). | | `422` | `unprocessable` | Spec parsed but could not be executed (rare — prefer relying on `validate`). | | `429` | `rate_limited` | Too many requests — see [Rate limits](#6-rate-limits). | | `503` | `platform_maintenance` | Upstream LLM provider temporarily unavailable; your credits are safe, retry later. | ## 6. Rate limits Endpoints are rate-limited per IP / per API key (limits may change; if you receive `429 Too Many Requests`, back off and retry with exponential backoff — a `Retry-After` header, when present, tells you how long to wait). `validate` is the endpoint you are expected to call the most while iterating on a spec; it has a generous but finite limit, so don't poll it in a tight loop. ## 7. Size limits Spec-accepting endpoints (`validate` and `POST /api/v1/experiments`) enforce these caps. The structural caps are checked before validation and before any cost estimate, so breaching one is cheap and fails fast: | Limit | Maximum | |-------|---------| | Request body | 512 KB | | `questionnaire` questions | 400 | | `options` per question | 26 | | `personas` dimensions | 40 | | `values` per persona dimension | 60 | | `experiment_config.sample_size` | 5000 | | `experiment_config.repeats` | 10 | | `experiment_config.item_effect_sample_size` | 5000 | An oversized body returns `413` with `{"error": "spec_too_large"}`. A breached structural cap is reported as a normal validation error, so on `validate` it comes back as `200` with `valid: false`, and on `POST /api/v1/experiments` as `400` `invalid_spec`. --- ## See also - [`/docs/agent`](/docs/agent) — agent documentation hub - [`workbook_creation_prompt.md`](/docs/agent/workbook_creation_prompt.md) — how to design the questionnaire/codebook/personas/config - [`experiment_json_spec.md`](/docs/agent/experiment_json_spec.md) — the exact JSON schema this API accepts - [`/pricing`](/pricing) — credit pricing details - [`/terms`](/terms) — terms of service