Deutero Python SDK
Official Python SDK for Deutero, the platform for AI moderated interviews. Use it to automate qualitative interviewing and scale user research from Python.
Run the full study lifecycle in code: create and configure studies, gate them with screening and consent, author linear or branching interview flows for the AI interviewer, recruit participants and embed interviews in your product, rehearse with AI personas, then monitor, search and cluster the interview transcripts.
Documentation: docs.deutero.ai · Quickstart · Changelog
Installation
pip install deutero
Or with uv:
uv add deutero
Requirements: Python 3.9+
Quick start
from deutero import Deutero
client = Deutero(api_key="your-api-key")
# 1. Create a project and a study
project = client.projects.create(name="Onboarding research")
study = client.studies.create(
project_id=project.id,
name="Why new users drop off",
survey_type="user_experience",
research_question="Where does onboarding lose people?",
target_population="Product managers at 50-500 person companies",
)
# 2. Welcome message, then the interview questions
client.welcome.set(study.id, message="Thanks for joining! This takes about 10 minutes.", consent=True)
client.questions.create(study.id, question="Walk me through your first day with the product.", max_turns=6)
client.questions.create(
study.id,
question="How easy was setup?",
qtype="scale",
scale={"minScale": 1, "maxScale": 5, "minLabel": "Very hard", "maxLabel": "Very easy"},
)
report = client.questions.validate(study.id)
print("Ethics check passed:", report.ethics_check.passed)
# 3. Rehearse with an AI participant before recruiting
personas = client.personas.generate(study.id, count=1)
run = client.simulations.run(study.id, persona_id=personas.personas[0].id)
print("Simulation started:", run.id, run.status)
# 4. Publish (studies start as drafts), then share the participation link
client.studies.publish(study.id)
recruitment = client.recruitment.get(study.id)
print("Share this link:", recruitment.participation_url)
# 5. Monitor responses
stats = client.studies.get_stats(study.id)
print(f"{stats.completed_interviews}/{stats.total_interviews} completed")
Authentication
Get your API key from the dashboard settings page. You can provide it in two ways:
Option 1 — Constructor argument:
client = Deutero(api_key="dtro_...")
Option 2 — Environment variable:
export DEUTERO_API_KEY="dtro_..."
client = Deutero() # reads from DEUTERO_API_KEY
The key is sent in the X-API-Key header.
Async support
Every method is available in an async variant via AsyncDeutero, with identical signatures:
import asyncio
from deutero import AsyncDeutero
async def main():
async with AsyncDeutero(api_key="your-api-key") as client:
projects = await client.projects.list()
for project in projects.projects:
print(project.name, project.study_count)
asyncio.run(main())
API Reference
All IDs accept either a str or a uuid.UUID. Optional arguments left as None are not sent, so the server default applies (and, for updates, the field is left unchanged). Responses are typed Pydantic models from deutero.models.
client.projects
| Method | Endpoint |
|---|---|
list() |
GET /projects |
create(name=, description=) |
POST /projects |
get(project_id) |
GET /projects/{id} |
update(project_id, name=, description=) |
PATCH /projects/{id} |
client.studies
| Method | Endpoint |
|---|---|
create(project_id=, name=, ...) |
POST /studies |
list(project_id) |
GET /projects/{id}/studies |
get(study_id) |
GET /studies/{id} |
update(study_id, ...) |
PATCH /studies/{id} |
get_stats(study_id) |
GET /studies/{id}/stats |
draft(survey_type=, language=, ...brief fields) |
POST /study-drafts |
draft_from_site(url=, language=) |
POST /study-drafts/from-site |
get_publication(study_id) |
GET /studies/{id}/publication |
validate(study_id) |
POST /studies/{id}/validate |
publish(study_id, acknowledge_validation_run_id=, acknowledge_credit_shortfall=) |
POST /studies/{id}/publish |
pause(study_id) |
POST /studies/{id}/pause |
survey_type is one of sociology, user_experience, customer_development, polling (see StudyType). model_tier is one of open_weights, standard, premium (see ModelTier; frontier is accepted as a deprecated alias for premium).
Drafting with AI. draft() turns a short research brief into study fields and draft_from_site() does the same from a product landing page. Neither stores anything: pass the draft's fields to create(), then fill in the questions with client.questions.generate().
draft = client.studies.draft(
survey_type="customer_development",
problem_hypothesis="Small agencies lose hours every week reconciling invoices",
customer_segment="Owners of 5-20 person design agencies",
)
study = client.studies.create(project_id=project.id, **draft.model_dump(exclude_none=True))
client.questions.generate(study.id, n_questions=8)
Publication. A study's status is draft, open, paused or closed; only an open study admits participants, and an open study's interview configuration is locked. publish() checks the plan's open-study limit, credits and a validation of everything participants will read. Ethical and blocking issues must be fixed; methodological issues can be acknowledged. A refused publish raises ConflictError, with the details in e.body.
run = client.studies.validate(study.id)
for issue in run.issues:
print(issue.severity, issue.code, issue.message)
if run.outcome in ("passed", "methodological_issues"):
client.studies.publish(study.id, acknowledge_validation_run_id=run.run_id)
client.studies.pause(study.id) # stop admitting participants and unlock editing
client.studies.publish(study.id) # reopen
client.welcome
| Method | Endpoint |
|---|---|
get(study_id) |
GET /studies/{id}/welcome |
set(study_id, message=, consent=) |
PUT /studies/{id}/welcome |
generate(study_id) |
POST /studies/{id}/welcome/generate |
list_translations(study_id) |
GET /studies/{id}/welcome/translations |
upsert_translation(study_id, target_language=, translation_text=) |
PUT /studies/{id}/welcome/translations |
delete_translation(study_id, translation_id) |
DELETE /studies/{id}/welcome/translations/{tid} |
client.screening and client.characteristics
Both have the same shape: screening gates participation with qualifying questions, and characteristics collect participant attributes for segmentation.
| Method | Endpoint |
|---|---|
get(study_id) |
GET /studies/{id}/screening |
set_settings(study_id, enabled=, ...) |
PUT /studies/{id}/screening/settings |
create_question(study_id, ...) |
POST /studies/{id}/screening/questions |
update_question(study_id, question_id, ...) |
PATCH /studies/{id}/screening/questions/{qid} |
delete_question(study_id, question_id) |
DELETE /studies/{id}/screening/questions/{qid} |
reorder_questions(study_id, question_ids=) |
PUT /studies/{id}/screening/questions/order |
client.screening.create_question(
study.id,
question="Do you use the product at work?",
options=["Yes", "No"],
acceptable_options=["Yes"],
)
client.screening.set_settings(study.id, enabled=True, disqualification_message="Thanks anyway!")
client.characteristics.create_question(study.id, question="What is your role?", variable="role",
options=["Engineer", "PM", "Designer"])
client.characteristics.set_settings(study.id, enabled=True)
client.questions
The linear interview question list.
| Method | Endpoint |
|---|---|
list(study_id) |
GET /studies/{id}/questions |
create(study_id, question=, qtype=, ...) |
POST /studies/{id}/questions |
generate(study_id, n_questions=, additional_instructions=) |
POST /studies/{id}/questions/generate |
update(study_id, question_id, ...) |
PATCH /studies/{id}/questions/{qid} |
delete(study_id, question_id) |
DELETE /studies/{id}/questions/{qid} |
reorder(study_id, question_ids=) |
PUT /studies/{id}/questions/order |
validate(study_id) |
POST /studies/{id}/questions/validate |
If qtype is omitted it is inferred: scale → scale, options → choices, slots → slots, expected_image → image_upload, otherwise text.
client.graph
The branching alternative to the linear list: an interview flow of steps, conditional paths, captured answers, data fetches and signals.
| Method | Endpoint |
|---|---|
get(study_id) |
GET /studies/{id}/graph |
set(study_id, flow=, expected_graph_version=) |
PUT /studies/{id}/graph |
patch(study_id, ops=, expected_graph_version=) |
PATCH /studies/{id}/graph |
check(study_id, flow=) |
POST /studies/{id}/graph/check |
get_signals(study_id) |
GET /studies/{id}/graph/signals |
import_questions(study_id) |
POST /studies/{id}/graph/import-questions |
activate(study_id) / deactivate(study_id) |
POST /studies/{id}/graph/activate / deactivate |
describe_node_types() |
GET /graph/node-types |
Flows and patch operations can be plain dicts in the API's JSON shape, or the typed FlowDocument, FlowNode, FlowEdge and PatchOp models. from and class are Python keywords, so on the models they are from_ and class_:
from deutero import FlowDocument, FlowEdge, FlowNode, PatchOp
# Start from the existing question list, then add a branch
client.graph.import_questions(study.id)
graph = client.graph.get(study.id)
result = client.graph.patch(
study.id,
expected_graph_version=graph.graph_version,
ops=[
PatchOp(op="update_node", id="q1", config={"text": "Walk me through your first week."}),
],
)
if result.valid:
client.graph.activate(study.id)
else:
for error in result.errors:
print(error.node_id, error.code, error.message)
set and patch save nothing unless the resulting flow is valid; invalid flows raise ValidationError (with the problems in e.body), and a stale expected_graph_version raises ConflictError.
client.recruitment
| Method | Endpoint |
|---|---|
get(study_id) |
GET /studies/{id}/recruitment |
update(study_id, short_url_slug=, max_responses=, clear_max_responses=, redirect_url=) |
PUT /studies/{id}/recruitment |
Append &source=<tag> and/or &participant_id=<your id> to participation links; they come back on interviews as web_source and external_participant_id.
client.embed
| Method | Endpoint |
|---|---|
list_keys() |
GET /embed/keys |
create_key(allowed_origins=, study_id=, metadata_max_bytes=) |
POST /embed/keys |
update_key(key_id, allowed_origins=, status=, metadata_max_bytes=) |
PATCH /embed/keys/{id} |
get_snippet(study_id, publishable_key=, mode=) |
GET /studies/{id}/embed/snippet |
create_key returns the publishable_key and signing_secret once only, so store them immediately.
client.personas and client.simulations
| Method | Endpoint |
|---|---|
personas.list(study_id) |
GET /studies/{id}/personas |
personas.create(study_id, content=) |
POST /studies/{id}/personas |
personas.update(study_id, persona_id, content=) |
PATCH /studies/{id}/personas/{pid} |
personas.delete(study_id, persona_id) |
DELETE /studies/{id}/personas/{pid} |
personas.generate(study_id, count=, save=) |
POST /studies/{id}/personas/generate |
simulations.run(study_id, persona_id=, persona=, model_tier=, deliver_signals=) |
POST /studies/{id}/simulations |
simulations.list(study_id, status=, limit=, offset=) |
GET /studies/{id}/simulations |
simulations.get(simulation_id) |
GET /simulations/{id} |
simulations.delete(simulation_id) |
DELETE /simulations/{id} |
Simulations run in the background. Poll until they finish:
import time
run = client.simulations.run(study.id, persona="A sceptical ICU nurse, short on time.")
while (sim := client.simulations.get(run.id)).status == "running":
time.sleep(10)
print(sim.status, sim.credits_used, sim.error)
client.interviews
| Method | Endpoint |
|---|---|
list(study_id, completed=, simulated=, test_runs=, external_participant_id=, started_after=, started_before=, limit=, offset=) |
GET /studies/{id}/interviews |
find_by_external_id(study_id, external_participant_id=, include_simulated=, include_test_runs=) |
GET /studies/{id}/interviews/by-external-id |
get(interview_id) |
GET /interviews/{id} |
get_transcript(interview_id) |
GET /interviews/{id}/transcript |
get_fetches_and_signals(interview_id) |
GET /interviews/{id}/fetches-and-signals |
Test runs — your own interviews through the dashboard's Try Interview / Preview — are left out of interview lists, transcripts and search unless you ask for them (test_runs=True / include_test_runs=True), and are flagged with test_run on each result.
client.transcripts
| Method | Endpoint |
|---|---|
list(study_id, completed=, include_simulated=, include_test_runs=, external_participant_id=, limit=, offset=) |
GET /studies/{id}/transcripts |
search(study_id, q=, mode=, question_id=, ...) |
GET /studies/{id}/search |
hits = client.transcripts.search(study.id, q="pricing is confusing", mode="semantic")
for hit in hits.hits:
print(f"{hit.score:.2f} {hit.participant_name}: {hit.content}")
client.analysis
| Method | Endpoint |
|---|---|
list_questions(study_id, category=) |
GET /studies/{id}/analysis/questions |
get_scale_responses(study_id, question_id=) |
GET /studies/{id}/analysis/responses/scale |
get_options_responses(study_id, question_id=) |
GET /studies/{id}/analysis/responses/options |
cluster(study_id, question_id=, n_clusters=) |
POST /studies/{id}/analysis/cluster |
get_optimal_clusters(study_id, question_id=) |
POST /studies/{id}/analysis/optimal-clusters |
get_latest_clustering(study_id) |
GET /studies/{id}/analysis/clustering/latest |
text_questions = client.analysis.list_questions(study.id, category="text")
question_id = text_questions.questions[0].id
k = client.analysis.get_optimal_clusters(study.id, question_id=question_id).optimal_k
result = client.analysis.cluster(study.id, question_id=question_id, n_clusters=k)
for cluster in result.data_points:
print(cluster.name, len(cluster.text))
client.webhooks
Organization-level event subscriptions.
| Method | Endpoint |
|---|---|
list_event_types() |
GET /webhooks/event-types |
list() |
GET /webhooks |
create(label=, url=, events=, enabled=) |
POST /webhooks |
update(webhook_id, ...) |
PATCH /webhooks/{id} |
delete(webhook_id) |
DELETE /webhooks/{id} |
rotate_secret(webhook_id) |
POST /webhooks/{id}/rotate-secret |
list_deliveries(webhook_id, event_type=, success=, ...) |
GET /webhooks/{id}/deliveries |
create and rotate_secret are the only calls that return the signing secret.
Receiving webhooks
deutero.webhooks verifies and parses what Deutero POSTs to you. It needs no API key, so it works in a receiver that never calls the API. Deliveries are signed with Standard Webhooks. The same function handles organization events (secret from client.webhooks.create) and interview-flow "Send a signal" steps (per-step secret from client.graph.get_signals).
from flask import Flask, request
from deutero import webhooks
from deutero.webhooks import InterviewCompletedEvent, StudyFullEvent
app = Flask(__name__)
@app.post("/deutero")
def receive():
try:
# Pass the raw body bytes. Re-serialized JSON will not verify.
event = webhooks.unwrap(request.get_data(), request.headers, secret=SIGNING_SECRET)
except webhooks.WebhookVerificationError:
return "", 400
if already_processed(event.webhook_id): # dedupe on the webhook-id header
return "", 204
if isinstance(event, InterviewCompletedEvent) and event.data.completed:
reward(event.data.external_participant_id)
elif isinstance(event, StudyFullEvent):
close_campaign(event.data.survey_id)
return "", 204
Event type |
Model | data fields |
|---|---|---|
interview.started |
InterviewStartedEvent |
interview_id, survey_id, participant_id, external_participant_id, web_source |
interview.completed |
InterviewCompletedEvent |
the above plus completed |
analysis.completed |
AnalysisCompletedEvent |
interview_id, survey_id |
simulation.completed |
SimulationCompletedEvent |
interview_id, survey_id |
study.created |
StudyCreatedEvent |
study_id, survey_id, name, project_id, created_via |
credits.exhausted |
CreditsExhaustedEvent |
organization_id |
study.full |
StudyFullEvent |
survey_id, survey_name |
survey_id is the study ID under its older name. Flow signals and any event type the SDK doesn't know yet come back as a plain WebhookEvent, with data as a dict. event.simulated is True for signals sent from a simulated interview. external_participant_id and web_source come from the participant's link and are not authenticated. The signature proves the event came from Deutero, not who the participant is.
Other helpers:
webhooks.verify(body, headers, secret=)checks the signature only.webhooks.parse_event(body, headers)parses without verifying (tests only).webhooks.sign(body, secret=, msg_id=)builds valid headers so you can exercise your receiver locally.
Deliveries older or newer than 5 minutes are rejected. Change this with tolerance= (in seconds), or pass tolerance=None to replay stored deliveries.
client.credits and client.health()
| Method | Endpoint |
|---|---|
credits.get_balance() |
GET /credits/balance |
health() |
GET /health |
Error handling
The SDK raises specific exceptions for different error types:
from deutero import (
Deutero,
APIError,
AuthenticationError,
ConflictError,
InsufficientCreditsError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
ValidationError,
)
client = Deutero(api_key="your-key")
try:
client.simulations.run(study_id, persona_id=persona_id, model_tier="premium")
except PermissionDeniedError as e:
print(f"Not available on your plan: {e.message}")
except AuthenticationError:
print("Invalid API key")
except NotFoundError:
print("Study or persona not found")
except InsufficientCreditsError as e:
print(f"Not enough credits: {e.message}")
except ValidationError as e:
print(f"Invalid request: {e.message}")
except RateLimitError:
print("Too many requests — retry later")
except APIError as e:
print(f"API error {e.status_code}: {e.message}")
Exception hierarchy
DeuteroError
├── APIError
│ ├── AuthenticationError (401)
│ │ └── PermissionDeniedError (403)
│ ├── NotFoundError (404)
│ ├── ConflictError (409)
│ ├── ValidationError (400, 422)
│ ├── InsufficientCreditsError (402)
│ ├── RateLimitError (429)
│ ├── BadGatewayError (502)
│ └── InternalServerError (5xx)
├── ConnectionError
├── TimeoutError
└── WebhookVerificationError
Configuration
Custom base URL
The default is https://dashboard.deutero.ai/study-api.
client = Deutero(
api_key="your-key",
base_url="https://staging.example.com/study-api",
)
Custom timeout
client = Deutero(
api_key="your-key",
timeout=300.0, # 5 minutes
)
Custom HTTP client
Bring your own httpx.Client for proxies, retries, or other transport customization. The SDK still adds its base URL (if your client has none) and the API key header:
import httpx
http_client = httpx.Client(
proxy="http://proxy.example.com:8080",
verify="/path/to/cert.pem",
)
client = Deutero(
api_key="your-key",
http_client=http_client,
)
Context manager
with Deutero(api_key="your-key") as client:
balance = client.credits.get_balance()
print(balance.net_available)
# Connection pool is automatically closed
Development
uv venv && uv pip install -e ".[dev]"
pytest
tests/fixtures/openapi.json is a copy of the API spec. tests/test_resources.py checks that every endpoint in it is reachable through the SDK and that its response parses into the matching model, so refresh that file when the API changes:
curl -s https://dashboard.deutero.ai/study-api/api/v1/openapi.json | python -m json.tool --indent 1 > tests/fixtures/openapi.json
pytest tests/test_resources.py
License
MIT — see LICENSE.
Metadata
Release files for deutero 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| deutero-0.2.0.tar.gz | 98.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| deutero-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 158.9 kB
Release files / deutero-0.2.0.tar.gz
| Download URL | deutero-0.2.0.tar.gz |
|---|---|
| Size | 98.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5964ad76e3af72e604a7db383907a10f14cdf93fda51bef838d85c8839b1ae7d
|
|
BLAKE2b-256 checksum How to use checksums |
44f613d9bb3586a886076c35d493d4825a3b56503fb4cf2a0d3790527554a4e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency logRelease files / deutero-0.2.0-py3-none-any.whl
| Download URL | deutero-0.2.0-py3-none-any.whl |
|---|---|
| Size | 60.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3f75c26ab882acbbc2249b710d3395727843eaeff40be8391d0385bb522d4cdb
|
|
BLAKE2b-256 checksum How to use checksums |
cc7eeb63b50ceb80c8a3cb5eeeea4311442e9ca42034d438b17161765899e96e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency log