Repository navigation
Studio API and API Keys
Everything the Studio app does goes through a REST API, and you can call the same API from a script: pull exports every night, download a bundle for the archive, start Run all from your pipeline, register a signed webhook. This page covers API keys, the basics of calling the API, a reference of the endpoints you are most likely to script, and worked examples.
A personal API key lets a script act as you. Create and revoke keys in Profile → API keys (full walkthrough in Account and Profile):
- Type a name in Key name (e.g. ci-pipeline) — up to 80 characters; the
field stops there and says "80 characters at most." — and click Create
key (or press
Enter). - Copy the token from "New key — copy it now, it won't be shown again:" with
Copy. Tokens look like
sck_followed by 43 characters. - To revoke, click Revoke on the key's row and confirm Revoke API key. The key stops working at once and stays listed as revoked.
The list shows each key's name, its first characters (sck_ab12cd34…) and
"used date" or "never used".
What a key can do:
- Everything you can do, in every organization you belong to, with your role in each. There are no per-key scopes and no read-only keys. Use one key per script, store it like a password, and revoke rather than share.
- Keys created in the app do not expire. The API accepts an optional
lifetime when you create a key through it (
expires_days, 1–730); such a key stops working after that time. - Only a hash of the token is kept; a lost key cannot be shown again — create a new one.
- Creating and revoking a key is recorded in the Activity of every
organization you belong to (
api_key.create,api_key.revoke), with the key's first characters (sck_ab12cd34) as the target and its name in the details — never the token. The organization-wide Activity is visible to owners and admins.
| Base URL | https://api.studio.siamang.org |
| Authentication | header Authorization: Bearer sck_… on every request |
| Request bodies | JSON (Content-Type: application/json), except file uploads (multipart) |
| Responses | JSON, except downloads (files, zips, .py, Markdown) |
| Identifiers | organizations by slug (acme-research), projects by numeric id, Saves by their number (seq) |
Find a project's id with GET /orgs/{org}/projects (example 1 below).
Errors come back as JSON with a detail field — usually a sentence, for
example {"detail": "only owners and admins can create projects"}; for
invalid input, a list of the fields that failed.
| Status | Meaning |
|---|---|
| 400 | the request is malformed (for example an unsupported export format) |
| 401 | missing, invalid or revoked key ("invalid API key") |
| 402 | not included in the organization's plan, or a plan limit reached — the message names the plan |
| 403 | your role is not enough ("insufficient role"), or the workspace is frozen and read-only |
| 404 | not found — also returned for organizations and projects you are not a member of |
| 409 | a conflict: someone saved since your base_seq, a colleague has a flow open that your Save would delete or rename, a run is already in progress, a flow did not pass the engine check, the project was never saved |
| 413 | an export or bundle is larger than 100,000 rows, or an upload is larger than 50 MB |
| 422 | a document or body did not validate, or a table cannot be written in the export format asked for |
| 429 | a rate limit (for example preview runs per hour) |
Note. The interactive API documentation (
/docs) is turned off on the hosted service. Use this page and the examples below.
(admin) marks endpoints that need the owner or admin role; the rest need membership of the organization, and anything that changes a project needs the member role or higher.
| Method and path | What it does |
|---|---|
GET /auth/me |
you and your memberships |
GET /orgs |
your organizations |
GET /orgs/{org}/projects |
the organization's projects: id, slug, name, current_snapshot_seq, responses
|
POST /orgs/{org}/projects (admin)
|
create a project: {"slug": "…", "name": "…", "template": "empty"}. A slug that breaks the rule answers 422 with "a project's address is 3 to 64 characters — lowercase letters, digits and hyphens — and starts and ends with a letter or a digit"; one the organization already has answers 409 ("project slug already exists in org") — unlike the app's New project dialog, the API does not add -2 for you |
GET /orgs/{org}/audit (admin)
|
the organization's activity log |
GET /auth/api-keys · POST /auth/api-keys · DELETE /auth/api-keys/{id}
|
list, create ({"name": "…", "expires_days": 90}), revoke your keys |
| Method and path | What it does |
|---|---|
GET /projects/{id}/database/tables |
the project's tables with row counts |
GET /projects/{id}/database/tables/{table}/schema |
a table's columns |
GET /projects/{id}/database/tables/{table}/preview?limit=100 |
up to limit rows (1–1,000; default 100), newest first when the table has a created_at or id column (as responses does). For responses, the answers come as one column per variable, in the questionnaire's order, as in the Data tab. Optional: q (up to 200 characters) searches the whole table — a row matches when any of its values contains the text, ignoring case (a response id, a respondent id, a panel id from the link, an answer); outcome = completed, screened_out or partial keeps those responses. With either, matched gives how many rows of the whole table match (the page holds at most limit of them); without, it is null. An unknown outcome answers 400 ("unknown outcome 'x'; one of completed, screened_out, partial"), as does outcome on a table without response outcomes ("table has no response outcomes") |
GET /projects/{id}/database/tables/{table}/export?format=… |
the whole table as a file: csv, xlsx, parquet, sav (SPSS), dta (Stata) or sqlite; SPSS and Stata files carry the codebook labels; up to 100,000 rows. For responses: one column per variable in the questionnaire's order, then the fieldwork columns url_<name>, duration_s, started_at, captcha, tab_switches, hidden_seconds, pastes, last_page (see Data Exports). A table the format cannot hold answers 422 ("The table cannot be written as .fmt: reason"). Each download is recorded in the project's Activity as data.export
|
DELETE /projects/{id}/database/responses/{response_id} (admin)
|
delete one response (recorded in Activity); the quota cells of its survey are recounted from the responses that remain, so a cell it filled goes down |
| Method and path | What it does |
|---|---|
GET /projects/{id}/snapshots?limit=50&offset=0 |
Saves, newest first (up to 200 per page). Each has validation_state, issues and flow_errors — the names of the flows that failed the engine check at that Save (they leave the state at warnings but cannot run) |
GET /projects/{id}/snapshots/{seq} |
one Save with every document's content |
GET /projects/{id}/snapshots/{seq}/diff?against={seq2} |
line diffs per changed document |
GET /projects/{id}/snapshots/{seq}/generated/survey/questionnaire.json |
that Save's questionnaire.py (use a flow's path, e.g. flows/tables.flow.json, for its .py) |
GET /projects/{id}/snapshots/{seq}/bundle?data=none or data=latest
|
the research bundle zip, optionally with the responses |
GET /projects/{id}/snapshots/{seq}/methods |
the Methods draft as Markdown |
POST /projects/{id}/snapshots |
Save: {"documents": {"<path>": <content>}, "message": "…", "base_seq": 17} (a null content deletes a flow). To rename a flow so that its schedules and comments follow it, delete the old path (null), add the new one (its name set to the new name) and name the pair in "renames": {"flows/old.flow.json": "flows/new.flow.json"}; a pair that is not deleted and added in the same Save answers 422 ("rename 'old' → 'new': a rename deletes the old flow and adds the new one in the same Save"), and a new name that already exists answers 409 ("a flow named 'new' already exists"). A Save that would delete or rename a flow a colleague has open answers 409 naming them |
POST /projects/{id}/snapshots/{seq}/restore |
restore as a new Save |
PUT /projects/{id}/snapshots/{seq}/tag |
pre-register: {"tag": "preregistered"}, or {"tag": null} to remove |
POST /projects/{id}/snapshots/{seq}/deposit |
deposit: {"target": "zenodo", "secret_key": "ZENODO_TOKEN", "publish": false, "sandbox": false, "data": "none"} (OSF: "target": "osf", "osf_node": "ab3cd") |
GET /projects/{id}/deposits |
the project's deposits with status, DOI and URL |
GET /projects/{id}/documents |
the project's documents |
GET /projects/{id}/documents/{path} |
a document's current content, e.g. studio/settings.json
|
POST /projects/{id}/documents/{path}/check |
run the engine's check on a document without saving: {"content": {…}}
|
| Method and path | What it does |
|---|---|
GET /projects/{id}/scripts |
the project's flows, in the order Run all runs them. Each has check_state (valid, warnings or error at the current Save) with its check_issues, and its newest finished run of its own (manual, scheduled or Live — not Run all's): last_run_status, last_run_started_at, last_run_finished_at (null when it has none since Reset history) |
POST /projects/{id}/scripts/{flow}/run |
run one flow on the current Save. A flow whose check_state is error answers 409: "flow 'name' did not pass the engine check at Save #N: open it, fix its errors and save before running it" |
POST /projects/{id}/scripts/run-all |
Run all |
GET /projects/{id}/runs?limit=50&type=…&path=… |
run history, newest first; type is analysis, analysis_all or connector
|
GET /projects/{id}/runs/{run_id}/outputs/download?path=… |
a link (valid 5 minutes) to one output file of a run |
GET /projects/{id}/reports |
stored reports; combined is true on Run all's combined report (the path set under Settings → Reports) |
GET /projects/{id}/reports/{path}/markdown |
a Markdown report ready to open elsewhere, {path} being its path from the list (for example outputs/tables/tables.md): a zip with the .md and the figures it refers to in one folder (tables.zip → tables/tables.md, tables/tables_fig_1.png), or the .md itself when it shows no figure. Errors: 400 "not a Markdown report", 404 "the stored report could not be read", 503 "object storage is not configured" |
| Method and path | What it does |
|---|---|
GET /projects/{id}/connectors |
declared connectors |
POST /projects/{id}/connectors/{name}/run (admin)
|
run a connector; its runs: GET /projects/{id}/runs?type=connector&path={name}
|
GET /projects/{id}/schedules · POST …/schedules
|
list; create {"kind": "run_all", "cron": "0 2 * * *"} or {"kind": "run_script", "flow_name": "tables", "cron": "…"}
|
PATCH /projects/{id}/schedules/{sid} · POST …/{sid}/run · DELETE …/{sid}
|
change {"enabled": false} / {"cron": "…"}, run now, remove |
GET /projects/{id}/secrets |
secret names only |
POST /projects/{id}/secrets (admin) · DELETE /projects/{id}/secrets/{key} (admin)
|
set {"key": "…", "value": "…"} (replaces an existing key), delete |
GET /projects/{id}/files |
stored files (uploads and run outputs), newest first by updated_at (when the content last changed, for a run output as for an upload); each has its content's sha256 (it may be null for an older run output) and, for a data upload, schema_summary: state (ready, pending or error), rows, sampled, columns, personal ([{"name", "kind"}], the personal-data hints) and suspected_missing (how many columns hold codes that look like missing codes) |
POST /projects/{id}/files |
upload (multipart field upload, up to 50 MB), stored as assets/<name> under the name Files cleans it to — Russian and Ukrainian letters spelled in Latin, accents dropped, other characters _ (or upload_<hash> when nothing is left), the extension kept (see Upload a file); a data file is then read for its columns |
POST /projects/{id}/files/schema |
what an upload holds, read as a Data file node with these parameters reads it: {"path": "assets/panel_wave2.csv", "params": {"delimiter": ";"}, "refresh": false} → state (ready, pending, error, missing, unsupported, or unread for a viewer when it has not been read that way yet) and, when ready, schema: the rows, how the file was read, whose codebook describes it, and the columns with their labels, types, scales, value labels, missing codes and hints — never an answer. A reading not kept yet is started and answers pending: ask again |
GET /projects/{id}/files/{file_id}/download |
{"url": …, "expires_in": 300} |
DELETE /projects/{id}/files/{file_id} |
delete a file |
| Method and path | What it does |
|---|---|
GET /projects/{id} |
the project, with current_snapshot_seq
|
PATCH /projects/{id} (admin)
|
rename: {"name": "…"}
|
DELETE /projects/{id} (admin)
|
delete the project permanently |
GET /projects/{id}/audit?limit=100 |
the project's activity (up to 500) |
GET /projects/{id}/deployments |
deployments with status and URL, plus responses (completed interviews so far — not partials, not screen-outs), closes_at (when it stops accepting responses), closes_at_manual (the date was set on the card), redirect_after_close and one_response_per_browser
|
POST /projects/{id}/deployments/{dep}/closing-date |
move the closing date without a new Save: {"closes_at": "2026-10-31T18:00:00Z"}, {"closes_at": null} for none, or {"from_save": true} to take the Save's date again. A date in the past answers 422 ("the closing date must be in the future — use Close to stop collecting now"); a preview answers 409 ("a preview has no closing date") |
POST /projects/{id}/deployments/{dep}/one-response-per-browser |
{"one_response_per_browser": true} or false; a preview answers 409 ("a preview takes no responses") |
GET /projects/{id}/dashboard/summary?days=14 |
fieldwork totals: responses (every row), completed (what quota cells and response caps count), screened_out, partial, partial_percent, last_response_at and per_day (the last days days, 1–90). respondents and duplicates are still there for older scripts, but they count interviews, not people: Studio cannot tell whether two responses came from the same person |
GET /projects/{id}/dashboard/frequencies?variable=… · GET …/dashboard/crosstab?rows=…&cols=…
|
the counts behind Data → Insights: a variable's bins and their base, or a crosstab's cells and totals (multiple is true for a multiple choice, whose shares add up to more than 100 %). By default over every row of responses; environment=main keeps that environment's responses plus rows no deployment claims (imported or sample data), and only_completed=true leaves out partial interviews — the rows a flow's Responses node with that Environment and Only completed responses reads |
GET /ingest/{survey_id}/status |
no key needed — whether a published survey is collecting, as its page asks when it opens: state is open, closed, paused, deadline (past its closing date) or full (a response cap is reached), with redirect_url (the environment's redirect_after_close, for a closed or past-deadline survey) and one_response_per_browser. An unknown survey answers 404 |
GET /orgs/{org}/webhooks · POST …/webhooks · DELETE …/webhooks/{id} (admin)
|
list, create, delete webhooks. Each listed webhook has signed (it has a secret) and unknown_events (subscribed names no event carries); creating one with an unknown event name answers 422 (see Schedules and Webhooks) |
GET /orgs/{org}/webhooks/deliveries?limit=50 (admin)
|
the delivery log (up to 200) |
A frozen workspace answers every change with 403; reading and exporting keep working.
The examples use curl and jq. Set your key once:
export SIAMANG_KEY="sck_…"
API=https://api.studio.siamang.org
AUTH="Authorization: Bearer $SIAMANG_KEY"curl -s -H "$AUTH" $API/orgs | jq -r '.[] | "\(.slug)\t\(.name)\t\(.plan)"'
curl -s -H "$AUTH" $API/orgs/acme-research/projects \
| jq -r '.[] | "\(.id)\t\(.slug)\tSave #\(.current_snapshot_seq)\t\(.responses) responses"'curl -s -H "$AUTH" -o responses.sav \
"$API/projects/42/database/tables/responses/export?format=sav"The file carries variable and value labels from the current codebook. Its
answer columns follow the questionnaire's order, and responses collected by an
earlier version of the survey page are read in today's layout — for example a
legacy "Other (please specify)" answer as the question's Other code in
<variable> plus its text in <variable>_other. The fieldwork columns
(url_<name>, duration_s, started_at, captcha, tab_switches,
hidden_seconds, pastes, last_page) come after them, so a script that reads columns by
position should read them by name. Use format=csv, xlsx, parquet, dta
or sqlite for other formats. A table above 100,000 rows answers 413 with
nothing generated. What every column means is on
Data Exports.
SEQ=$(curl -s -H "$AUTH" $API/projects/42 | jq .current_snapshot_seq)
curl -s -H "$AUTH" -OJ "$API/projects/42/snapshots/$SEQ/bundle?data=latest"
# → brand-2026-s17-data.zip-OJ keeps the file name Studio sends: <project-slug>-s<N>.zip, or
-data.zip with data=latest.
RUN=$(curl -s -X POST -H "$AUTH" $API/projects/42/scripts/run-all | jq .id)
while true; do
STATUS=$(curl -s -H "$AUTH" "$API/projects/42/runs?type=analysis_all&limit=5" \
| jq -r ".[] | select(.id == $RUN) | .status")
echo "run $RUN: $STATUS"
[ "$STATUS" = completed ] || [ "$STATUS" = failed ] && break
sleep 10
doneA run's status goes queued → running → completed or failed. Run all
runs the flows in dependency order (a flow after the flows whose tables it
reads) and does not stop at a failed flow: the flows that do not need its
tables still run, and the run ends as failed once every flow has had its
turn. The last line of its log names them — "failed: names" (then
"; skipped: names" for flows that needed a failed one). The reports of the
flows that succeeded are stored even then, and the combined report is written
from them as "Combined report (incomplete)" (the log says "combined report
(incomplete): reports/report.md"). Starting a second Run all while one is in
progress answers 409 ("a run-all is already in progress for this project"). A
project with no flows answers 409 ("project has no flows to run"). To run one
flow use POST $API/projects/42/scripts/tables/run; it answers 409 while that
flow is already running ("a run for this flow is already in progress") or when
it did not pass the engine check at the current Save.
To see where each flow stands without walking the run history, read the flows list:
curl -s -H "$AUTH" $API/projects/42/scripts \
| jq -r '.[] | "\(.name)\t\(.check_state)\t\(.last_run_status // "never")\t\(.last_run_finished_at // "")"'curl -s -H "$AUTH" "$API/projects/42/runs?type=analysis&limit=1" \
| jq -r '.[0] | "run \(.id):", (.outputs[] | select(.downloadable) | .path)'
URL=$(curl -s -G -H "$AUTH" \
--data-urlencode "path=report.html" \
"$API/projects/42/runs/311/outputs/download" | jq -r .url)
curl -s -o report.html "$URL"The download link is valid for 5 minutes and needs no key.
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
$API/orgs/acme-research/webhooks \
-d '{"url": "https://hooks.example.com/siamang",
"secret": "whsec_3f1c…",
"events": ["deploy.failed", "run.failed"],
"enabled": true}'Needs the owner or admin role and the Plus plan. The event names, the payloads
and the signature format are in
Schedules and Webhooks. The same webhook —
secret and event filter included — can also be added in Organization
settings → Integrations → Webhooks; either way the secret is never returned
by the API or shown again. Creating and deleting webhooks is recorded in the
organization's Activity (webhook.create, webhook.delete).
curl -s -G -H "$AUTH" \
--data-urlencode "q=PANEL-83921" --data-urlencode "limit=20" \
"$API/projects/42/database/tables/responses/preview" \
| jq '{matched, ids: [.rows[].id]}'q searches every row of the table, not only the newest ones — useful for an
erasure request about an old response. matched is how many rows contain the
text; rows holds the newest limit of them. Add outcome=completed (or
screened_out, partial) to narrow it further. Deleting a response is
DELETE /projects/42/database/responses/{id} (owner or admin).
curl -s -H "$AUTH" $API/projects/42/reports | jq -r '.[] | select(.path | endswith(".md")) | .path'
curl -s -H "$AUTH" -OJ "$API/projects/42/reports/outputs/tables/tables.md/markdown"
# → tables.zip (tables/tables.md and its figures), or tables.md when it has nonecurl -s https://api.studio.siamang.org/ingest/3f9a1c07b2de/status
# → {"state": "open", "redirect_url": null, "one_response_per_browser": false}This is the public check a survey page makes as it opens, so it needs no key;
use the survey_id from GET /projects/{id}/deployments. state is open,
closed, paused, deadline or full. It is rate-limited (429 "rate limit
exceeded; slow down"), so poll it gently.
Some settings have no editor in the app yet — the environments list, and editing or removing a connector. Until they do, you can save a new version of the settings document yourself. Any member can do this; it is an ordinary Save and shows in History.
# 1. The current settings document and the current Save number
curl -s -H "$AUTH" "$API/projects/42/documents/studio/settings.json" | jq .content > settings.json
SEQ=$(curl -s -H "$AUTH" $API/projects/42 | jq .current_snapshot_seq)
# 2. Edit settings.json — e.g. set "max_responses" of "main" in "environments",
# give it "closes_at": "2026-12-01T00:00:00Z" and
# "redirect_after_close": "https://example.org/thanks",
# or remove an entry from "connectors".
# 3. Save it as a new version
jq -n --slurpfile s settings.json --argjson base "$SEQ" \
'{documents: {"studio/settings.json": $s[0]}, message: "Raise the main cap", base_seq: $base}' \
| curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
--data @- "$API/projects/42/snapshots" | jq '{seq, validation_state}'- Send the whole document, not just the part you changed.
-
base_seqmakes the Save fail with 409 instead of overwriting a colleague's Save made in between; fetch again and retry. - A malformed document is refused with 422 and nothing is saved. An environment
name uses lowercase letters, digits and
-, starting with a letter, and must be unique;max_responsesis a whole number of at least 1. Connector names must be unique and use lowercase letters, digits and_. - New environments, caps,
closes_atandredirect_after_closetake effect when you next publish to that environment; until then its Distribute card adds "the environment’s closing settings changed — republish env to apply" when the settings name a redirect, or an earlier closing date, that the live deployment does not have yet. A closing date set on the Distribute card (closes_at_manual) outlives a republish:closes_atfrom the settings applies to that deployment again only after{"from_save": true}on itsclosing-dateendpoint (Use the Save’s date in the app). A cap counts completed interviews only (not screen-outs or partial responses). The survey closes at the earlier ofcloses_atand the questionnaire's own deadline, andredirect_after_closeis used only when it is anhttp://orhttps://address. - A Python version other than 3.11 in
runtimemakes the Save warnings (RUNTIME_PYTHON): Studio runs flows on 3.11, and the setting only tells a research bundle which version to ask for.
- Account and Profile
- Schedules and Webhooks
- Data Exports
- Reproducibility: Code, Bundles and Citation
- Security and Privacy
← Schedules and Webhooks · Studio contents · Working Together →
siamang · Siamang Studio · Free for noncommercial use · Commercial licensing · Wiki source: wiki/
Getting started
Survey design
- Variables and Measurement
- Question Types
- Pages Blocks and Structure
- Visibility and Branching
- Quotas
- Scripts
Validate & simulate
Data & analysis
Reporting
Frontend & deploy
Tooling
More
Get started
Account & workspace
- Sign Up and Sign In
- Account and Profile
- Organizations and Team
- Projects
- Project Settings
- Plans, Trial and Billing
Build the questionnaire
- The Builder
- Question Types
- Codebook and Variables
- Logic and Branching
- Quotas and Randomization
- MaxDiff and Conjoint
- Theme and Branding
- Scripts
- Importing Questionnaires
- Question Bank and Library
- Testing Your Survey
- AI Assistant
Fieldwork
- Publishing and Environments
- Links, QR, Embeds, Access
- Panel Providers
- Email Invitations
- What Respondents See
- Live Monitoring
Data
Analysis & output
- Analysis Flows
- Node Reference
- Cleaning and Weighting
- Coding Open Answers
- Reports
- History and Versions
- Reproducibility
Integrations & admin
Reference