empirio.ai Developer Docs
empirio.ai offers four integration surfaces:
- MCP Server — connect AI agents (ChatGPT, Claude, and compatible hosts). Available on every plan, including Free.
- REST API — manage surveys and pull responses over HTTPS. Available on every plan, including Free.
- Webhooks — receive new responses in real time. Available on every plan, including Free.
- CLI —
@empirio-ai/clinpm package; OAuth 2.1 login and every REST operation on the command line. Available on every plan, including Free.
Write replay policy depends on the surface. REST requires Idempotency-Key on every write; the CLI, n8n, Zapier, and Make satisfy that REST requirement automatically, with explicit overrides on their normal write commands, actions, and modules for callers that need to reuse a retained value. MCP instead offers an optional idempotency_key: omit it unless the client can retain the value and resend the same key and arguments after an uncertain result.
Beta — This API is in beta. Endpoints, request/response shapes, and behavior may change without prior notice.
REST API
Overview
Use the REST API to manage surveys and read response data over HTTPS.
Base URL
https://platform.empirio.ai/rest/v1
All requests must be made over HTTPS. HTTP requests are rejected with a 308 redirect.
Authentication
Include a Bearer token in every request. Two token types are accepted:
Authorization: Bearer sk_live_… # REST API key (dashboard)
Authorization: Bearer <oauth-token> # OAuth 2.1 access token (used by the CLI)
API keys are created in Settings → Integrations. OAuth access tokens are issued via the /mcp-oauth-token endpoint and are used automatically by the empirio CLI after empirio login. A token's scope is a space-separated list of these tokens, and nothing spells "all":
| Scope | Access |
|---|---|
surveys | Create, read, edit, delete, publish, unpublish, revert, duplicate surveys |
responses | List, aggregate, export, delete responses; cross-tabulation; chart export |
webhooks | Register, inspect, update and remove delivery endpoints. Also requires responses — see Endpoints in the sidebar |
Rate limits
Every limit on a request that authenticates is counted per account, not per credential. The read and write windows key on the account behind the presented token — rest:read:<user_id> and rest:write:<user_id> — so a second API key, or an API key alongside an OAuth token, splits one budget instead of adding a second one. Issue extra keys for rotation or to separate scopes, not for more throughput. The failed-authentication budget is the one exception and is keyed the other way, on the credential that was presented; it is described under the table.
| Metric | Limit |
|---|---|
| Read operations | 60 / minute per account |
| Write operations | 20 / minute per account |
| Failed authentications | 120 / minute per credential (per IP when no credential is presented) |
POST /surveys and POST /surveys/{survey_id}/duplicate | 10 / hour and 50 / day per account, shared between the two — charged only when the request reaches the work it pays for |
Reads and writes are counted separately, so a burst of writes does not consume read budget. The MCP server counts under its own keys again: an agent on MCP and a script on REST do not share a window.
The failed-authentication limit is the exception named above. It counts failed authentications only, and it is charged to the credential that was presented, not to the address it came from. A request that authenticates is charged to its account and never to either. So a client hammering a rotated or mistyped key exhausts that key's budget and nobody else's, and callers sharing an egress IP — the normal case behind Zapier, Make and n8n Cloud — are unaffected by one another. Only a request presenting no credential at all keys on the address, and such a request never reaches a key lookup, so it cannot refuse a valid key from the same address.
The limit exists because a request with no valid credential has no account to charge. It bounds the cost of a flood at one indexed lookup per attempt rather than acting as a brute-force defence, for which an sk_ key carries far too much entropy. Its residual, stated plainly: a flood presenting a different credential on every request gets a fresh budget each time and is not stopped here. That costs the same one lookup per request as a flood on a valid key, which this design already accepts, and belongs at the edge rather than in the application.
Every authenticated response carries the current state of the account's window:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the window (60 read / 20 write) |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) at which the window resets |
When a limit is exceeded the API returns 429. Retry-After and the three X-RateLimit-* headers then describe the budget that refused the call — which is not always the one the request would otherwise have been counted against, since survey creation (POST /surveys and POST /surveys/{survey_id}/duplicate alike) has its own hourly and daily quotas. A refusal with no window behind it, such as the chart-export concurrency cap, sends neither Retry-After nor the headers: a number describing a different budget is worse than no number.
Write operations include POST, PATCH, and DELETE. Read operations are GET requests.
The per-account survey_create limit is applied in addition to the global write rate limit and is intended to prevent account-wide runaway creation. It is not affected by idempotency replays — re-sending a request with the same Idempotency-Key does not consume additional quota.
Admin exemption
Accounts with the admin role bypass every per-account rate limit (read, write, and the survey_create hourly/daily caps), whichever token type they present. An admin token authenticates, so it is never charged to the failure budget. Nor is it affected by anyone else's failures: since the budget is keyed on the presented credential, an admin key is refused by this gate only if that key itself has been failing — which a valid one does not. This exemption is intended for internal tooling and test automation — every other account remains subject to the limits documented above.
Idempotency
Write operations require an idempotency key via header:
- Header:
Idempotency-Key: <value>(max 128 characters)
Repeat requests with the same key within the idempotency window return the original response without re-executing the operation. The exceptions are the two export operations: their download_url and expires_at are re-issued on every reply, because a download link is signed for about an hour while the idempotency window is far longer — a replayed link would otherwise arrive already expired. Everything else in the response, row_count included, is the original.
A key is bound to the request body it was first used with. Reusing it for a different request returns idempotency_key_conflict (409) rather than the earlier response, so a stale key cannot silently answer for a write that never ran. Generate a fresh key per distinct request; reuse one only to replay that exact request.
Response envelope
Every response — success and failure alike — is wrapped. There is no unwrapped variant, so read ok first and take the payload from data:
{
"ok": true,
"data": {
"survey_id": "b6f2c1d0-8a3e-4c57-9f21-7d4e8a1b3c95",
"title": "Customer Feedback Survey"
}
}
The per-endpoint schemas below describe the contents of data.
Error responses
All errors use the matching failure envelope. Branch on error.code, not on error.message: the message is human-readable English and is not a stable contract.
{
"ok": false,
"error": {
"code": "not_found",
"message": "Survey not found."
}
}
| Code | Status | Description |
|---|---|---|
validation_error | 400 | Request parameters failed validation |
idempotency_required | 400 | Idempotency key required but not provided |
not_authorized | 401 | Missing or invalid API key |
insufficient_scope | 403 | Key does not have the required scope |
plan_features_exceeded | 403 | The survey uses a feature the survey owner's plan does not include |
plan_feature_required | 403 | The requested operation is a feature the survey owner's plan does not include |
forbidden | 403 | Action not permitted |
not_found | 404 | Resource or route not found |
method_not_allowed | 405 | HTTP method not allowed for this endpoint |
conflict | 409 | The request conflicts with current state — a concurrently modified working draft, say — and stays so until the request changes |
survey_not_canonical | 409 | The stored survey holds a value the canonical contract no longer accepts, so this write cannot carry it forward. Nothing in the request caused it and resending it unchanged will not clear it — repair the stored value first |
idempotency_in_progress | 409 | Duplicate request is still being processed |
idempotency_key_conflict | 409 | The Idempotency-Key was already used for a different request body |
rate_limited | 429 | Rate limit exceeded |
edge_error | 500 | An upstream service the operation depends on failed. The message is the fixed text Upstream request failed — the underlying cause is logged server-side, not returned |
no_chartable_data | 400 | The survey has no answers to chart yet. Every chart format answers this way; collect responses and request the export again |
export_error | 500 | An export could not be produced |
export_timeout | 500 | A chart export was still rendering when the wait expired. Request it again, or narrow it with date or answer filters — a render measures seconds on a typical survey and about a minute on the largest ones, so this is rare |
upload_error | 500 | The export artifact could not be stored |
export_expired | 404 | The exported file is no longer available. Idempotent replays re-sign the link, but the file itself can be removed before the 24-hour replay window ends; run the export again with a new idempotency_key |
query_error | 500 | A database operation failed |
response_counts_unavailable | 500 | GET /surveys could not read the per-survey response counts it returns alongside each survey. The survey list itself is unaffected — retry the request. Unlike the other 5xx codes this one carries the upstream failure text rather than fixed text, so do not surface its message to an end user |
idempotency_finalize_error | 500 | The operation ran but its idempotency record could not be committed. Retry with the same Idempotency-Key |
internal_error | 500 | Unexpected server error |
5xx messages are replaced with fixed text before they leave the server; the underlying cause is logged with the request ID rather than returned. There is no exception — every 5xx code has a canned message, and a test pins that list against the message table so a new one cannot slip through uncanned.
Recommended survey flow
POST /surveys— create a draftPATCH /surveys/{survey_id}— optional iterative editsPOST /surveys/{survey_id}/publish— publish the draft
Question Types
Write endpoints accept questions as an array of objects. Every question needs a type and question text plus any type-specific fields listed below.
Option lists take two forms. Wherever a table below says string[] — options, matrix_rows, matrix_columns — each entry may be a plain label, in which case the server assigns the identifier, or the {"option_id": "…", "label": "…"} object GET /surveys/{survey_id} returns. Send the object form to keep an existing option's identity: logic rules reference option ids, translations are keyed by them, and stored answers resolve their labels through them, so a read-modify-write cycle that drops the id orphans all three. The two forms may be mixed in one list, and an object without option_id is the same as a plain label. option_id is opaque: send back exactly what you were given rather than composing one.
Required means required. A field marked Yes below is refused at creation when it is missing or empty — validation_error: options is required for 'single-choice' questions. — and an edit cannot empty it again either. These are runtime rules, not JSON Schema required entries, so a schema-validating client will happily build a request the server rejects.
Floors. single-choice, multiple-choice, dropdown, ranking, image-single-choice and image-multiple-choice need at least two options (Question type 'single-choice' requires at least 2 options). matrix rows and columns and text-rating columns need at least one each; a 1×1 matrix is accepted.
image-single-choice and image-multiple-choice carry a picture per option. Each entry of options is an object with a label and an image_url. image_url takes either the storage path survey_get reported — which is what makes a read sendable straight back — or an https URL, which the write fetches: the image is checked to be a PNG, JPEG or WebP within 5 MB, 6000 px per side and 16 megapixels, stored, and the option keeps the resulting path. null removes a picture. A URL that cannot be fetched refuses the whole call and names it, so a picture round is never created half-finished.
Server-assigned values. Some fields are filled in or overridden regardless of what you send; each is noted in its table. They come back from survey_get, so a read-modify-write round trip will show fields you never set.
Selection
Single choice — single-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | string[] | Yes | Answer labels, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
allow_other | boolean | No | Allow a free-text "other" answer |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "single-choice",
"question": "How did you hear about us?",
"options": ["Social media", "Search engine", "Friend"],
"allow_other": true
}
Image single choice — image-single-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | object[] | Yes | {label, image_url} entries, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "image-single-choice",
"question": "Which area did you like best?",
"options": [
{ "label": "Lava gorge", "image_url": "https://cdn.example.com/gorge.jpg" },
{ "label": "Arena", "image_url": "https://cdn.example.com/arena.jpg" }
]
}
Image multiple choice — image-multiple-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | object[] | Yes | {label, image_url} entries, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
min_selections | integer | No | Fewest options a respondent must tick |
max_selections | integer | No | Most options a respondent may tick |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "image-multiple-choice",
"question": "Which materials clean water?",
"min_selections": 1,
"options": [
{ "label": "Gravel", "image_url": "https://cdn.example.com/gravel.png" },
{ "label": "Cotton", "image_url": "https://cdn.example.com/cotton.webp" }
]
}
Multiple choice — multiple-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | string[] | Yes | Answer labels, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
allow_other | boolean | No | Allow a free-text "other" answer |
min_selections | integer | No | Minimum options the respondent must select (≥ 1, ≤ options.length). See the note below — it does not set required. |
max_selections | integer | No | Maximum options the respondent may select (≥ 1, ≥ min_selections, ≤ options.length). |
subtitle | string | Server-assigned when omitted | Defaults to a localized "Multiple selection possible" with show_subtitle: true. A subtitle you send is kept, and show_subtitle: false is honoured. |
show_subtitle | boolean | No | Whether to display the subtitle |
min_selections and required are different fields. min_selections does not change the stored required flag, so both survey_get.draft and survey_get.published keep reporting required: false. It is still enforced on the respondent: while the question is visible, a submission with fewer than min_selections selections is refused, so the question is effectively mandatory. A question hidden by a logic rule stays optional, and its min_selections is not applied.
{
"type": "multiple-choice",
"question": "Which features do you use?",
"options": ["Dashboard", "Reports", "API"],
"randomize_options": true,
"min_selections": 1,
"max_selections": 2
}
Dropdown — dropdown
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | string[] | Yes | Answer labels, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
allow_other | boolean | No | Allow a free-text "other" answer |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "dropdown",
"question": "Select your country",
"options": ["Germany", "Austria", "Switzerland"]
}
Yes / No — yes-no
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "yes-no",
"question": "Would you use this product again?",
"required": true
}
Rating
Star rating — rating
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
min | — | Never | Not accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error. |
max | number | No | 2–10 (default 5) |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "rating",
"question": "How would you rate our service?",
"required": true,
"max": 5
}
Thumbs — thumbs
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
min | — | Never | Not accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error. |
max | number | No | 2–10 (default 5) |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "thumbs",
"question": "Did you enjoy this experience?",
"required": true,
"max": 5
}
Number rating — scale
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
min | — | Never | Not accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error. |
max | number | No | 2–20 (default 10) |
scale_labels | { min?: string, max?: string } | Server-assigned when omitted | Label texts for scale ends. Defaults to {"min": "Low", "max": "High"}, which survey_get then reports. |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "scale",
"question": "How satisfied are you?",
"max": 10,
"scale_labels": {
"min": "Not at all",
"max": "Extremely"
}
}
Text rating — text-rating
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
matrix_columns | string[] | Yes | Label texts shown on each rating button, at least 1. These are this type's answer options: a respondent's stored answer is a col_… id from this list, not an opt_… one. |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "text-rating",
"question": "How do you feel?",
"matrix_columns": ["Bad", "Neutral", "Good", "Great"]
}
NPS — nps
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "nps",
"question": "How likely are you to recommend us?",
"required": true
}
Text & Input
Short text — text
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "text",
"question": "What is your name?",
"required": true
}
Long text — text-long
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "text-long",
"question": "Please share additional feedback"
}
Email — email
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "email",
"question": "Your email address",
"required": true
}
Phone — phone
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "phone",
"question": "Your contact number"
}
Number — number
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "number",
"question": "How many employees does your company have?",
"required": true
}
Date — date
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "date",
"question": "When did you first use our product?"
}
Advanced
Matrix — matrix
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
matrix_rows | string[] | Yes | Row labels, at least 1 |
matrix_columns | string[] | Yes | Column labels, at least 1 |
randomize_rows | boolean | No | Shuffle row order per respondent |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "matrix",
"question": "Rate each feature",
"matrix_rows": ["Ease of use", "Performance", "Design"],
"matrix_columns": ["Poor", "Fair", "Good", "Excellent"]
}
Ranking — ranking
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | string[] | Yes | Items to rank, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "ranking",
"question": "Rank by importance",
"options": ["Speed", "Reliability", "Price", "Support"]
}
Content — content
A display-only block: it asks nothing, produces no answer, and appears in no export column.
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Title / label |
content | string | Yes | Body text |
image_url | string | No | Header image for the block — an absolute URL or a storage asset path. Max 2000 characters, no format check (the editor writes asset paths here). Returned by survey_get and preserved on round trip. Content questions only. |
content_image_paths | string[] | No | Storage paths of the images embedded in content. At most 50 entries, 200 characters each. The editor derives this from the body HTML — send back what survey_get returned rather than composing it. Content questions only. |
subtitle, show_subtitle | — | Never | Not supported on this type; sending either returns Question type 'content' does not support these fields: subtitle, show_subtitle. |
{
"type": "content",
"question": "Section 2: Demographics",
"content": "This section collects demographic information."
}
Privacy Policy — privacy
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Policy title |
required | boolean | No | Ignored. Always stored as true — consent cannot be optional. A false you send is accepted and overridden, and survey_get reports true |
content | string | Yes | Policy text shown to the respondent |
privacy_checkbox_label | string | No | Label displayed next to the consent checkbox |
subtitle, show_subtitle | — | Never | Not supported on this type; sending either returns Question type 'privacy' does not support these fields: subtitle, show_subtitle. |
{
"type": "privacy",
"question": "Privacy Policy",
"content": "I agree to the processing of my data.",
"privacy_checkbox_label": "I accept",
"required": true
}
Surveys
Create, edit, publish, duplicate, and delete surveys.
get/surveys
List surveys
List all surveys owned by or shared with the authenticated user, including each survey's effective draft, scheduled, live, or ended status.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| status | query | "all" | "draft" | "published" | No | Filter by publication status |
| limit | query | integer | No | Max results to return (default 50) |
| offset | query | integer | No | Number of results to skip (default 0) |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_list`.
- surveys array of objectrequired
- id string (uuid)required
Survey ID (UUID)
- title stringrequired
- description string (nullable)required
- is_published booleanrequired
- status "draft" | "scheduled" | "live" | "ended"required
Effective respondent-facing state: `draft` is unpublished, `scheduled` is published but before `schedule.start_at`, `live` currently accepts responses, and `ended` is published but at or after `schedule.end_at`.
- role "owner" | "editor" | "viewer"required
- response_count integerrequired
- created_at stringrequired
ISO 8601 timestamp
- updated_at stringrequired
ISO 8601 timestamp
- draft_updated_at string (nullable)required
When the working draft last changed, or null when the survey has no draft. `updated_at` tracks the survey row, which a survey_edit deliberately leaves untouched — so this is the only timestamp that moves for an unpublished edit.
- id string (uuid)required
- total integerrequired
Total number of accessible surveys, ignoring pagination.
- surveys array of objectrequired
Example response
{
"ok": true,
"data": {
"surveys": [
{
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"title": "Customer Satisfaction Q1",
"description": "Quick 3-minute check-in with our Pro customers.",
"is_published": true,
"status": "live",
"role": "owner",
"response_count": 142,
"created_at": "2026-01-12T09:24:00.000Z",
"updated_at": "2026-04-02T16:03:18.000Z",
"draft_updated_at": null
},
{
"id": "c1b2a3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"title": "Onboarding feedback",
"description": null,
"is_published": false,
"status": "draft",
"role": "editor",
"response_count": 0,
"created_at": "2026-03-20T11:00:00.000Z",
"draft_updated_at": "2026-03-22T14:05:00.000Z",
"updated_at": "2026-03-21T08:30:00.000Z"
}
],
"total": 2
}
}post/surveys
Create survey draft
Create a new survey draft (unpublished). Questions are provided as an array — each needs a type and question text plus type-specific fields. See the Question Types section for the full list of supported types and their fields.
All field-level details are documented directly in the request schema attributes.
Logic rules and translations can be created in the same call: give each question a ref and reference that ref from logic_rules and from the keys of translations.questions.<locale>. The response reports the assigned question_id for each ref under question_ids_by_ref.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
Required
- mode "manual" | "ai"required
Creation mode: 'manual' for explicit content, 'ai' for prompt-based generation
- questions array of object
Array of question objects (each must have a valid type). At most 1000 questions per survey.
- question_id object
Not accepted. Question identifiers are assigned by the server — use `ref` to reference a question within the same request.
- ref string
Caller-chosen name for this question. Stored, returned by `survey_get`, and usable in place of `question_id` on later edits — so it is also how you reference the question from `logic_rules`, `translations` and insert positions in the request that creates it. Unique within the survey, and never equal to a `question_id`. `survey_create` reports the assigned `question_id` for every question it creates. On `survey_edit` the `ref` belongs on the `question_operations` entry itself, beside `op`, not inside this payload.
- type "rating" | "thumbs" | "text-rating" | "text" | "text-long" | "nps" | "single-choice" | "multiple-choice" | "dropdown" | "yes-no" | "scale" | "date" | "email" | "phone" | "number" | "ranking" | "matrix" | "image-single-choice" | "image-multiple-choice" | "content" | "privacy"required
Question type.
- question string
Question text.
- required boolean
Whether an answer is required. Ignored on `privacy` questions, which always store `true` — consent cannot be optional.
- options array of string | object
Answer options (max 100). Either labels or `{label, ref}` objects — `ref` names the option so `logic_rules` and `translations` can reference it in this same request.
- subtitle string
Optional subtitle. On `multiple-choice` and `image-multiple-choice`, omitting it materializes the locale-specific multi-select hint and sets `show_subtitle: true`; set `show_subtitle: false` to hide that hint.
- show_subtitle boolean
Whether the subtitle is shown.
- content string
Body content for content/privacy questions. Rich text: at most 2000 characters of text, markup not counted.
- image_url string
Header image for a `content` question — either an absolute URL or a storage asset path. `content` questions only; any other type refuses it.
- content_image_paths array of string
Storage paths of the images embedded in `content` (at most 50). The editor derives this from the body HTML; send back what `survey_get` returned. `content` questions only.
- min object
Not accepted. The lower bound is always 1 and is set by the server.
- max integer
Upper bound. rating/thumbs: 2–10, scale: 2–20. Ignored for types without max support.
- scale_labels object
Scale endpoint labels.
- min string
Minimum scale label.
- max string
Maximum scale label.
- min string
- allow_other boolean
Allow an additional free-text 'other' answer.
- randomize_options boolean
Randomize option order for each respondent.
- min_selections integer
Minimum number of options the respondent must select. multiple-choice only. Implies the question is required when ≥ 1.
- max_selections integer
Maximum number of options the respondent may select. multiple-choice only. Must be ≥ min_selections (if set) and ≤ options.length.
- matrix_rows array of string | object
Row labels for `matrix` questions. Required for `matrix` and unsupported on `text-rating`, whose choices belong in `matrix_columns`. Either labels or `{label, ref}` objects.
- matrix_columns array of string | object
Column labels for `matrix` questions and answer-button labels for `text-rating`. Required for both types. Either labels or `{label, ref}` objects.
- randomize_rows boolean
Randomize matrix row order for each respondent.
- privacy_checkbox_label string
Label shown next to the privacy consent checkbox.
- translations map of object
Per-locale question translations keyed by locale (for example `de-DE`).
- question_id object
- ai_prompt string
Prompt text for AI-driven survey creation (ai mode)
- metadata object
Survey metadata object. Field-level descriptions define title and display mode. The whole object is optional, and so is every field in it. `title` is the one conditional: manual mode cannot create a survey without one, and `survey_create` answers `validation_error: metadata.title is required in manual mode.` if it is missing, empty or `null`. AI mode writes its own title — leave `title` out, or send it empty or `null`, to keep the generated one; send a value to override it, as `welcome_page` and `end_page` do.
- title string (nullable)
Survey title shown to respondents and in the dashboard. Required in manual mode. In AI mode it overrides the generated title; omit it, or send `""` or `null`, to keep the generated one.
- display_mode "all" | "single"
Question rendering mode: `all` shows all questions on one page; `single` shows one question per page.
- title string (nullable)
- welcome_page object
Welcome page configuration shown before the first question. `description` is rich text; `description_image_paths` lists the storage paths of the images it embeds — send back what `survey_get` returned rather than composing it.
- enabled booleanrequired
Whether the welcome page is shown before the first question.
- title string
Welcome page title.
- description string
Welcome page description. Rich text: at most 2000 characters of text, markup not counted.
- description_image_paths array of string
Storage paths of the images embedded in the welcome page `description` (at most 50). The editor derives this from the description HTML; send back what `survey_get` returned.
- enabled booleanrequired
- end_page object
End page configuration shown after the last question. `description` is rich text; `description_image_paths` lists the storage paths of the images it embeds — send back what `survey_get` returned rather than composing it.
- enabled booleanrequired
Whether the end page is shown after the final question.
- title string
Custom end-page title. If omitted, the default title is used.
- description string
Custom end-page description text. If omitted, the default text is used. Rich text: at most 2000 characters of text, markup not counted.
- description_image_paths array of string
Storage paths of the images embedded in the end page `description` (at most 50). The editor derives this from the description HTML; send back what `survey_get` returned.
- show_title boolean
Explicit title visibility toggle. Use `false` to hide the title even if `title` is set.
- show_description boolean
Explicit description visibility toggle. Use `false` to hide the description even if it exists.
- show_button boolean
Whether to show a call-to-action button on the end page.
- button_text string
Button label shown on the end page.
- button_url string
Destination URL opened by the button and used as redirect target when `auto_redirect` is enabled.
- auto_redirect boolean
When `true`, respondents are redirected automatically to `button_url` after `redirect_delay`.
- redirect_delay string
Auto-redirect delay as a duration-like string.
- enabled booleanrequired
- design object
Design customization for survey styling — template, primary color and logo. `logo_url` here is an `https` URL to import: the write fetches the image, checks it, stores it, and the survey keeps the resulting storage path. A path cannot be sent on create — it would have to contain the id of a survey that does not exist yet — but `survey_edit` takes either form.
- template "standard" | "modern" | "cosmos" | "aurora" | "nebula" | "eclipse" | "forest" | "amber"
Visual survey template. Available: `standard` (light, default), `modern` (dark), `cosmos` (dark, depth), `aurora` (light, gradient), `nebula` (dark, purple), `eclipse` (dark, purple accent), `forest` (dark, green), `amber` (dark, blue).
- primary_color string
Primary accent color as a CSS hex color — `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA` (for example `#4F46E5`).
- logo_url string (nullable)
Survey logo, given as an `https` URL to import. The write fetches the image, verifies it is a PNG, JPEG or WebP within the size and dimension limits, stores it, and the survey keeps the resulting storage path — which is what `survey_get` reports and what `survey_edit` takes back. A storage path is not accepted here: it would have to contain the id of a survey that does not exist yet.
- template "standard" | "modern" | "cosmos" | "aurora" | "nebula" | "eclipse" | "forest" | "amber"
- settings object
Survey behavior settings. Field-level descriptions define the supported allowlisted keys.
- show_progress boolean
Whether to show a progress indicator during completion.
- show_question_numbers boolean
Whether to show numeric question labels (1, 2, 3...).
- auto_advance boolean
Whether the respondent flow automatically advances after a question is answered when the question type supports it. Defaults to true, which is also the editor's default — omit this field unless the user asked for something else. Set it to false only on an explicit request to make respondents press the continue button after every question.
- hide_branding boolean
Whether to hide empirio branding in the survey footer.
- master_locale string
Primary survey locale in BCP 47 format (for example `en-US`, `de-DE`). Defaults to the user's preferred locale when omitted during creation.
- multi_language_enabled boolean
Whether multi-language answering is enabled.
- additional_locales array of string
Additional enabled locales in BCP 47 format.
- show_progress boolean
- logic_rules array of object
Logic rules the new survey should have, as a plain list rather than operations — there is nothing to patch yet, and it is the same shape `survey_get` reports. `question_id` and `target_question_id` name a question's `ref` from this same request, because no question has an identifier until the survey is created. Rule identifiers are assigned by the server; use `logic_rule_operations` on `survey_edit` to change a rule afterwards.
- question_id stringrequired
Stable question identifier (`question_id`) from `survey_get` or webhook payloads. A `ref` declared on a question added in the same request is also accepted here.
- condition "equals" | "not-equals" | "contains" | "greater-than" | "less-than"required
Comparison operator.
- values array of stringrequired
Values used for the comparison. For equals/not-equals each entry must be a value the referenced question can produce: "true"/"false" for yes-no, the option id for choice questions, the score as a string for nps/rating/scale/thumbs.
- action "skip-to" | "show-question" | "hide-question" | "end-survey"required
Action when the condition matches.
- target_question_id string
Target question for skip/show/hide actions.
- question_id stringrequired
- schedule object
The survey's run window. `start_at` activates it, `end_at` deactivates it, both on their own — a survey with a start is *scheduled* rather than live, and one past its end is *ended*. Unlike every other section this is not working-draft content: it applies immediately and needs no `survey_publish`. Send `null` for either to remove it. Example: `{ "start_at": "2026-09-08T06:00:00Z", "end_at": "2026-09-22T21:59:59Z" }`.
- start_at string (nullable)
When the survey activates itself. `null` removes the start and leaves the survey open from the moment it is published.
- end_at string (nullable)
When the survey deactivates itself. Must be later than `start_at`. `null` removes the end and leaves the survey open indefinitely.
- start_at string (nullable)
- translations object
Translations for the new survey. Question translations are keyed by a question's `ref` from the same request rather than by `question_id`, which does not exist yet.
- metadata map of object
Per-locale metadata translation patches keyed by locale.
- questions map of map of object
Per-locale question translation patches keyed by locale then `question_id`.
- metadata map of object
Example — Gives each question a `ref` and points `logic_rules` and `translations` at those refs. The response reports the assigned `question_id` for each ref under `question_ids_by_ref`.
{
"mode": "manual",
"metadata": {
"title": "Onboarding check-in"
},
"settings": {
"master_locale": "en-US",
"additional_locales": [
"de-DE"
]
},
"questions": [
{
"ref": "consent",
"type": "yes-no",
"question": "May we ask a few follow-up questions?",
"required": true
},
{
"ref": "details",
"type": "text-long",
"question": "What could we do better?"
}
],
"logic_rules": [
{
"question_id": "consent",
"condition": "equals",
"values": [
"false"
],
"action": "hide-question",
"target_question_id": "details"
}
],
"translations": {
"metadata": {
"de-DE": {
"title": "Onboarding-Check"
}
},
"questions": {
"de-DE": {
"consent": {
"question": "Dürfen wir ein paar Rückfragen stellen?"
}
}
}
}
}Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_create`. `public_url` is a direct link to the draft survey — it only becomes functional after `survey_publish`.
- id string (uuid)
Survey ID (UUID)
- public_url string
- public_url_note string
- preview_url string
Preview URL showing the current working draft. Responses submitted via this link are not counted.
- question_ids_by_ref map of string
The identifier assigned to each question that carried a `ref`, keyed by that `ref`. Present only when the request used refs. Shorthand for the question half of `created`, which reports every question and option whether or not it was named.
- created object
Every identifier this call assigned, in the order the request listed them. Use it to address what you just created without a follow-up `survey_get` — including the options. In AI mode the entries have no caller refs, but the generated question and option IDs are still returned here.
- questions array of objectrequired
Every question created, in request order.
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- ref string
The `ref` you gave this question, when you gave one.
- options array of object
Every option created on this question, in request order.
- option_id stringrequired
The identifier assigned to this option.
- ref string
The `ref` you gave this option.
- field "options" | "matrix_rows" | "matrix_columns"required
Which axis of the question this option belongs to.
- option_id stringrequired
- question_id stringrequired
- questions array of objectrequired
- warnings array of string
Non-fatal problems, such as a translation for a locale the survey does not enable.
- id string (uuid)
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"public_url": "https://www.empirio.ai/s/9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"public_url_note": "Survey is not published yet. This link will only work after publishing.",
"preview_url": "https://www.empirio.ai/p/7c1e5a90-3b42-4d8f-9a6b-1e2f3a4b5c6d"
}
}get/surveys/{survey_id}
Get survey details
Get both complete states of a survey: draft is the current working draft and published is the most recently published definition, or null when the survey has never been published. The top-level status and schedule describe current respondent access.
Options, matrix_rows, and matrix_columns in both states are returned as {option_id, label} objects. Use draft as the basis for edits and published.questions[].question_id for response analytics. Translation entries with status: "pending" may contain master-locale text as an editable placeholder; that text is not a completed target-locale translation.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | The survey ID |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_get`, with the complete working draft and most recently published definition separated explicitly.
- id string (uuid)required
Survey ID (UUID)
- draft objectrequired
Complete current working draft. This is the state survey_edit builds on and survey_publish publishes.
- metadata objectrequired
Survey metadata, in the shape `survey_create`/`survey_edit` take it.
- title stringrequired
- description string (nullable)required
- display_mode "all" | "single"
- questions array of objectrequired
- question_id string (nullable)required
Stable question ID. `null` only for legacy rows without an assigned id.
- ref string
The caller-chosen name this question was given, when it has one. Usable in place of `question_id` wherever a question is addressed.
- type stringrequired
Question type (e.g. text, multiple-choice, matrix, rating).
- question stringrequired
Question title (human-readable prompt).
- required boolean
- subtitle string
- show_subtitle boolean
- options array of object
Answer options for selection questions. Each option has a stable option_id and display label.
- option_id string
Stable opaque option identifier. Use in option_operations to rename, delete, or reorder. Absent on legacy options stored without one — echo the entry back without the key and the next write assigns a real identifier.
- label stringrequired
Display label for this option.
- ref string
The caller-chosen name this option was given, when it has one. Usable in place of `option_id` wherever an option is addressed. Absent on options nobody named — most of them.
- image_url string
The option's image, as the storage path it is held under. Present only on `image-single-choice` and `image-multiple-choice` options that have one. Send it back unchanged to keep the image, an `https` URL to replace it, or `null` to remove it. This is a path and not a link: to display the image, exchange it for a signed URL through the asset endpoints.
- option_id string
- matrix_rows array of object
Matrix row options with stable identifiers.
- option_id string
Stable opaque option identifier. Use in option_operations to rename, delete, or reorder. Absent on legacy options stored without one — echo the entry back without the key and the next write assigns a real identifier.
- label stringrequired
Display label for this option.
- ref string
The caller-chosen name this option was given, when it has one. Usable in place of `option_id` wherever an option is addressed. Absent on options nobody named — most of them.
- image_url string
The option's image, as the storage path it is held under. Present only on `image-single-choice` and `image-multiple-choice` options that have one. Send it back unchanged to keep the image, an `https` URL to replace it, or `null` to remove it. This is a path and not a link: to display the image, exchange it for a signed URL through the asset endpoints.
- option_id string
- matrix_columns array of object
Matrix column options with stable identifiers.
- option_id string
Stable opaque option identifier. Use in option_operations to rename, delete, or reorder. Absent on legacy options stored without one — echo the entry back without the key and the next write assigns a real identifier.
- label stringrequired
Display label for this option.
- ref string
The caller-chosen name this option was given, when it has one. Usable in place of `option_id` wherever an option is addressed. Absent on options nobody named — most of them.
- image_url string
The option's image, as the storage path it is held under. Present only on `image-single-choice` and `image-multiple-choice` options that have one. Send it back unchanged to keep the image, an `https` URL to replace it, or `null` to remove it. This is a path and not a link: to display the image, exchange it for a signed URL through the asset endpoints.
- option_id string
- min number
- max number
- scale_labels object
Optional labels for the min and max endpoints of a scale/rating question.
- min string
- max string
- allow_other boolean
- randomize_options boolean
- randomize_rows boolean
- min_selections number
- max_selections number
- content string
- image_url string
- content_image_paths array of string
- privacy_checkbox_label string
- translations map of object
Optional per-locale translation payload and generation status keyed by BCP-47 locale. Treat `status` as authoritative: `pending` content may be a copy of the master-locale text.
- question_id string (nullable)required
- welcome_page objectrequired
Welcome page configuration, in the shape the write contract takes it.
- enabled boolean
- title string
- description string
- description_image_paths array of string
- end_page objectrequired
End page configuration, in the shape the write contract takes it.
- enabled boolean
- title string
- description string
- description_image_paths array of string
- design objectrequired
Design block, in the shape `design` takes on write.
- template string
- primary_color string
- logo_url string
- settings objectrequired
Survey behaviour settings, in the shape `settings` takes on write. `mark_required` is not reported: the setting has no toggle in the product and the write does not accept it.
- show_progress boolean
- show_question_numbers boolean
- auto_advance boolean
- hide_branding boolean
- master_locale string
- multi_language_enabled boolean
- additional_locales array of string
- logic_rules array of objectrequired
The survey's logic rules as they stand. Change them with `logic_rule_operations` on `survey_edit`.
- rule_id stringrequired
Stable rule identifier accepted by survey_edit logic operations.
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- condition stringrequired
- values array of stringrequired
- action stringrequired
- target_question_id string
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- rule_id stringrequired
- translations objectrequired
Survey-level translations, keyed the way `translations` takes them on write. Question translations are reported inside each question, not here.
- metadata map of object
Per-locale survey metadata translations and their generation status.
- metadata map of object
- access_mode "open" | "invite_only" (nullable)required
- updated_at string (nullable)required
When the stored working draft last changed. Null only when no working-draft row exists and the canonical survey is used as its effective draft.
- metadata objectrequired
- published object (nullable)required
Most recently published definition, or null when the survey has never been published.
- schedule objectrequired
The survey's run window, reported in the same shape `survey_create` and `survey_edit` take it. Both `null` when the survey has none. This is the one section that belongs to neither definition: it is a property of the survey itself, so it applies immediately and never appears under `has_pending_draft_changes`.
- start_at string (nullable)required
- end_at string (nullable)required
- is_published booleanrequired
- status "draft" | "scheduled" | "live" | "ended"required
Effective respondent-facing state: `draft` is unpublished, `scheduled` is published but before `schedule.start_at`, `live` currently accepts responses, and `ended` is published but at or after `schedule.end_at`.
- role "owner" | "editor" | "viewer"required
- public_url stringrequired
Public survey URL.
- preview_url string
Preview URL showing the current working draft. Responses submitted via this link are not counted.
- has_pending_draft_changes booleanrequired
True when there are unpublished edits in the working draft.
- plan_gate object | object | objectrequired
Whether `survey_publish` would accept this draft under the owner's current plan. `blocked` names every uncovered feature, `clear` means no plan feature blocks publishing, and `unresolved` means the owner's plan could not be read. The editor shows the same list in its upgrade dialog before the publish button is pressed; this is that answer, ahead of the call. Resolved against the survey OWNER's plan — a collaborator's own upgrade cannot lift the gate.
- created_at stringrequired
ISO 8601 timestamp
- updated_at stringrequired
ISO 8601 timestamp
- id string (uuid)required
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"draft": {
"metadata": {
"title": "Customer Satisfaction Q2 draft",
"description": "Quick 3-minute check-in with our Pro customers.",
"display_mode": "single"
},
"questions": [
{
"question_id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"type": "single-choice",
"question": "Which product area do you use most?",
"required": true,
"options": [
{
"option_id": "opt_a1b2c3d4",
"label": "Dashboard"
},
{
"option_id": "opt_bbbb2222",
"label": "Reports"
},
{
"option_id": "opt_cccc3333",
"label": "API"
}
],
"allow_other": true
},
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"type": "multiple-choice",
"question": "Which features matter most to you?",
"options": [
{
"option_id": "opt_dddd4444",
"label": "Pricing"
},
{
"option_id": "opt_eeee5555",
"label": "Support"
},
{
"option_id": "opt_ffff6666",
"label": "Speed"
}
],
"randomize_options": true
},
{
"question_id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"type": "rating",
"question": "How would you rate our service?",
"required": true,
"min": 1,
"max": 5
},
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"type": "nps",
"question": "How likely are you to recommend us?",
"required": true
}
],
"welcome_page": {
"enabled": true,
"title": "Welcome!"
},
"end_page": {
"enabled": true,
"title": "Thank you!"
},
"design": {
"template": "standard",
"primary_color": "#4F46E5"
},
"settings": {
"show_progress": true,
"show_question_numbers": true,
"auto_advance": true,
"master_locale": "en-US"
},
"logic_rules": [],
"translations": {},
"access_mode": "open",
"updated_at": "2026-04-05T10:15:00.000Z"
},
"published": {
"metadata": {
"title": "Customer Satisfaction Q1",
"description": "Quick 3-minute check-in with our Pro customers.",
"display_mode": "single"
},
"questions": [
{
"question_id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"type": "single-choice",
"question": "Which product area do you use most?",
"required": true,
"options": [
{
"option_id": "opt_a1b2c3d4",
"label": "Dashboard"
},
{
"option_id": "opt_bbbb2222",
"label": "Reports"
},
{
"option_id": "opt_cccc3333",
"label": "API"
}
],
"allow_other": true
},
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"type": "multiple-choice",
"question": "Which features matter most to you?",
"options": [
{
"option_id": "opt_dddd4444",
"label": "Pricing"
},
{
"option_id": "opt_eeee5555",
"label": "Support"
},
{
"option_id": "opt_ffff6666",
"label": "Speed"
}
],
"randomize_options": true
},
{
"question_id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"type": "rating",
"question": "How would you rate our service?",
"required": true,
"min": 1,
"max": 5
},
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"type": "nps",
"question": "How likely are you to recommend us?",
"required": true
}
],
"welcome_page": {
"enabled": true,
"title": "Welcome!"
},
"end_page": {
"enabled": true,
"title": "Thank you!"
},
"design": {
"template": "standard",
"primary_color": "#4F46E5"
},
"settings": {
"show_progress": true,
"show_question_numbers": true,
"auto_advance": true,
"master_locale": "en-US"
},
"logic_rules": [],
"translations": {},
"access_mode": "open",
"version": 7
},
"schedule": {
"start_at": null,
"end_at": "2026-04-30T21:59:59Z"
},
"is_published": true,
"status": "ended",
"role": "owner",
"public_url": "https://www.empirio.ai/s/9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"preview_url": "https://www.empirio.ai/p/7c1e5a90-3b42-4d8f-9a6b-1e2f3a4b5c6d",
"has_pending_draft_changes": true,
"plan_gate": {
"status": "blocked",
"blocking_features": [
{
"id": "custom_logo",
"label": "Custom logo",
"required_plan": "plus"
}
],
"required_plan": "plus",
"owner_plan": "lite"
},
"created_at": "2026-01-12T09:24:00.000Z",
"updated_at": "2026-04-02T16:03:18.000Z"
}
}patch/surveys/{survey_id}
Edit survey draft
Apply changes to a survey's working draft (does not publish). Include only the sections you want to change as top-level fields.
Re-sending an edit whose result already matches the draft is safe: it answers 200 with an empty applied_changes and a warning saying nothing was changed, rather than a conflict. A 409 from this operation means a genuine concurrent modification.
All field-level details and supported operation variants are documented directly in the request schema attributes.
See the request-body Examples for complete manual editing scenarios.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | The survey ID to edit |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
Required
- mode "manual" | "ai"required
Edit mode: 'manual' applies provided changes, 'ai' generates changes from ai_prompt
- question_operations array of object | object | object | object
Question operations (`add`, `update`, `delete`, `reorder`). At most 1000 operations per request.
- option_operations array of object | object | object | object
Option operations (`add`, `rename`, `delete`, `reorder`). At most 1000 operations per request.
- metadata object
Partial metadata patch.
- title string
Survey title shown to respondents and in the dashboard.
- display_mode "all" | "single"
Question rendering mode: `all` shows all questions on one page; `single` shows one question per page.
- title string
- welcome_page object
Partial welcome-page patch.
- enabled boolean
Whether the welcome page is shown before the first question.
- title string
Welcome page title.
- description string
Welcome page description. Rich text: at most 2000 characters of text, markup not counted.
- description_image_paths array of string
Storage paths of the images embedded in the welcome page `description` (at most 50). The editor derives this from the description HTML; send back what `survey_get` returned.
- enabled boolean
- end_page object
Partial end-page patch.
- enabled boolean
Whether the end page is shown after the final question.
- title string
Custom end-page title. If omitted, the default title is used.
- description string
Custom end-page description text. If omitted, the default text is used. Rich text: at most 2000 characters of text, markup not counted.
- description_image_paths array of string
Storage paths of the images embedded in the end page `description` (at most 50). The editor derives this from the description HTML; send back what `survey_get` returned.
- show_title boolean
Explicit title visibility toggle. Use `false` to hide the title even if `title` is set.
- show_description boolean
Explicit description visibility toggle. Use `false` to hide the description even if it exists.
- show_button boolean
Whether to show a call-to-action button on the end page.
- button_text string
Button label shown on the end page.
- button_url string
Destination URL opened by the button and used as redirect target when `auto_redirect` is enabled.
- auto_redirect boolean
When `true`, respondents are redirected automatically to `button_url` after `redirect_delay`.
- redirect_delay string
Auto-redirect delay as a duration-like string.
- enabled boolean
- logic_rule_operations array of object | object | object
Logic-rule operations (`add`, `update`, `delete`). At most 200 operations per request; a survey may hold at most 200 logic rules in total (enforced on write).
- design object
Design customization for survey styling — template, primary color and logo. `logo_url` takes either the storage asset path `survey_get` returned (`<owner_id>/<survey_id>/…`) or an `https` URL to import, and `null` removes the logo.
- template "standard" | "modern" | "cosmos" | "aurora" | "nebula" | "eclipse" | "forest" | "amber"
Visual survey template. Available: `standard` (light, default), `modern` (dark), `cosmos` (dark, depth), `aurora` (light, gradient), `nebula` (dark, purple), `eclipse` (dark, purple accent), `forest` (dark, green), `amber` (dark, blue).
- primary_color string
Primary accent color as a CSS hex color — `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA` (for example `#4F46E5`).
- logo_url string (nullable)
Survey logo, in either of the two forms it takes. A **storage path** — `<owner_id>/<survey_id>/…`, what `survey_get` returns — keeps or restores an image that is already stored. An **`https` URL** imports one: the write fetches it, verifies it is a PNG, JPEG or WebP within the size and dimension limits, stores it, and the survey keeps the resulting path. At most 512 characters either way. `null` removes the logo.
- template "standard" | "modern" | "cosmos" | "aurora" | "nebula" | "eclipse" | "forest" | "amber"
- settings object
Partial settings patch.
- show_progress boolean
Whether to show a progress indicator during completion.
- show_question_numbers boolean
Whether to show numeric question labels (1, 2, 3...).
- auto_advance boolean
Whether the respondent flow automatically advances after a question is answered when the question type supports it. Defaults to true, which is also the editor's default — omit this field unless the user asked for something else. Set it to false only on an explicit request to make respondents press the continue button after every question.
- hide_branding boolean
Whether to hide empirio branding in the survey footer.
- master_locale string
Primary survey locale in BCP 47 format (for example `en-US`, `de-DE`). Defaults to the user's preferred locale when omitted during creation.
- multi_language_enabled boolean
Whether multi-language answering is enabled.
- additional_locales array of string
Additional enabled locales in BCP 47 format.
- show_progress boolean
- schedule object
The survey's run window. `start_at` activates it, `end_at` deactivates it, both on their own — a survey with a start is *scheduled* rather than live, and one past its end is *ended*. Unlike every other section this is not working-draft content: it applies immediately and needs no `survey_publish`. Send `null` for either to remove it. Example: `{ "start_at": "2026-09-08T06:00:00Z", "end_at": "2026-09-22T21:59:59Z" }`.
- start_at string (nullable)
When the survey activates itself. `null` removes the start and leaves the survey open from the moment it is published.
- end_at string (nullable)
When the survey deactivates itself. Must be later than `start_at`. `null` removes the end and leaves the survey open indefinitely.
- start_at string (nullable)
- translations object
Translation patches for metadata and question content.
- metadata map of object
Per-locale metadata translation patches keyed by locale.
- questions map of map of object
Per-locale question translation patches keyed by locale then `question_id`.
- metadata map of object
- ai_prompt string
Prompt text for AI-driven survey editing (ai mode)
Example — Adds a dropdown question at the end of the survey.
{
"mode": "manual",
"question_operations": [
{
"op": "add",
"position": {
"type": "end"
},
"question": {
"type": "dropdown",
"question": "Select your department:",
"options": [
"Engineering",
"Marketing",
"Sales",
"Support",
"HR"
],
"required": true
}
}
]
}Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_edit`.
- id string (uuid)required
Survey ID (UUID)
- warnings array of string
- schedule object
The run window as it now stands, present only when the request carried a `schedule`. Both instants are reported even when the request named one of them, because a window is only meaningful as a pair. It sits outside `applied_changes`, which describes what happened to the working draft — the run window is a property of the survey and is already in effect.
- start_at string (nullable)required
- end_at string (nullable)required
- applied_changes objectrequired
Summary of what the edit actually changed. Added/updated/deleted entries carry `question_id` so callers can correlate applied changes against cached references.
- question_changes objectrequired
- added array of objectrequired
- question_id string (nullable)required
- ref string
The `ref` the request gave this question, when it gave one. Present only for caller-chosen names — an id the server minted internally names nothing the caller can look up.
- question stringrequired
- type stringrequired
- position map of objectrequired
- updated array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- changed_fields array of stringrequired
- question_id stringrequired
- deleted array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- question stringrequired
- type stringrequired
- question_id stringrequired
- reordered booleanrequired
- added array of objectrequired
- logic_rule_changes objectrequired
- added integerrequired
- updated integerrequired
- deleted integerrequired
Rules actually removed from the resulting draft, including rules removed automatically because a referenced question was deleted.
- added_rule_ids array of stringrequired
The `rule_id` of each logic rule this call created, in the order they were added. Use them directly in a later `logic_rule_operations` update or delete instead of re-reading the survey. Shorter than `added` when the server discarded a rule it could not reconcile — `added` counts requested operations, this counts rules that exist.
- metadata_changes map of object
- welcome_page_changes map of object
- end_page_changes map of object
- design_changes map of object
- settings_changes map of object
- translation_changes_summary map of object
- option_changes object
Summary of option-level changes applied by option_operations.
- added array of objectrequired
Options added, each with the identifier it was assigned — no follow-up `survey_get` needed to address what you just created.
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- label stringrequired
- option_id string
The identifier assigned to this option. Present whenever the option was created, including on a question this same request created.
- ref string
The `ref` you gave this option, when you gave one.
- image_url string
The durable storage path assigned to the option image. Present when this add set an image.
- question_id stringrequired
- renamed array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- option_id stringrequired
- label stringrequired
- image_url string (nullable)
The durable storage path when this rename set an image, or null when it removed one. Omitted when the image was unchanged.
- question_id stringrequired
- deleted array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- option_id stringrequired
- question_id stringrequired
- reordered array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- field "options" | "matrix_rows" | "matrix_columns"required
- question_id stringrequired
- added array of objectrequired
- question_changes objectrequired
- id string (uuid)required
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"applied_changes": {
"question_changes": {
"added": [
{
"question_id": "q_new-b1c4d5e6-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
"question": "Any other feedback?",
"type": "text",
"position": {
"type": "end"
}
}
],
"updated": [
{
"question_id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"changed_fields": [
"question",
"required"
]
}
],
"deleted": [],
"reordered": false
},
"logic_rule_changes": {
"added": 0,
"updated": 0,
"deleted": 0,
"added_rule_ids": []
},
"option_changes": {
"added": [
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"label": "New Feature"
}
],
"renamed": [
{
"question_id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"option_id": "opt_a1b2c3d4",
"label": "Main Dashboard"
}
],
"deleted": [],
"reordered": []
},
"metadata_changes": {
"title": "Customer Satisfaction Q1 — Updated"
}
}
}
}delete/surveys/{survey_id}
Delete survey
Delete a survey and its responses. Owner only. Takes effect immediately and cannot be undone through the API.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID to delete. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_delete`.
- deleted truerequired
Example response
{
"ok": true,
"data": {
"deleted": true
}
}post/surveys/{survey_id}/publish
Publish survey
Publish the existing survey working draft. If already published, updates the published survey from the current working draft. Does not create new surveys or accept inline content edits. Returns public_url plus effective status; a future schedule returns scheduled and a public_url_note instead of implying the URL already accepts responses.
Plan-gated features. Publishing is refused with plan_features_exceeded when the survey uses a feature the survey OWNER's plan does not include (a collaborator's own plan is not read). survey_get.plan_gate answers the same question before the call, and survey_create / survey_edit warn as soon as a draft crosses the line.
| Feature | Name | Minimum plan |
|---|---|---|
premium_template | Premium template | Lite |
logic_rules | Logic jumps | Lite |
custom_colors | Custom colors | Plus |
custom_logo | Custom logo | Plus |
access_restriction | Access restriction | Plus |
hide_branding | Hide branding | Pro |
multi_language | Multi-language surveys | Pro |
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Existing survey ID to publish |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_publish`.
- id string (uuid)
Survey ID (UUID)
- public_url stringrequired
Public survey URL. Always show this link to the user after publishing.
- status "draft" | "scheduled" | "live" | "ended"required
Effective respondent-facing state: `draft` is unpublished, `scheduled` is published but before `schedule.start_at`, `live` currently accepts responses, and `ended` is published but at or after `schedule.end_at`.
- public_url_note string
Present when the survey is scheduled: explains when the public URL starts accepting responses.
- id string (uuid)
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"public_url": "https://www.empirio.ai/s/9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"status": "live"
}
}post/surveys/{survey_id}/unpublish
Unpublish survey
Unpublish a survey and move it back to draft status. Owner or editor — taking a survey off-line is the inverse of putting it on-line, and the same collaborators may do both.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID to unpublish. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_unpublish`.
- id string (uuid)
Survey ID (UUID)
- is_published false
- id string (uuid)
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"is_published": false
}
}post/surveys/{survey_id}/revert-draft
Revert survey draft
Replace a survey's working draft with the currently published version, discarding every unpublished edit. The discarded draft cannot be recovered through the API; the live survey and its responses are unaffected.
A survey that has never been published answers 400 — there is no published version to fall back on. A draft that already matches the published version answers 200 with discarded: false. Before and after question counts are snapshot sizes and may be equal despite additions, removals, or modifications. A 409 means a concurrent modification.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey whose working draft is replaced by the currently published version. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_revert_draft`.
- id string (uuid)required
Survey ID (UUID)
- discarded booleanrequired
True when the working draft held unpublished work that this call replaced. False when the draft already matched the published version — a success, with nothing lost.
- discarded_changes objectrequired
What the revert cost. Present on every success; all-empty when nothing was discarded.
- sections array of stringrequired
Which parts of the survey the discarded draft differed in: `questions`, `metadata`, `logic_rules`, `welcome_page`, `end_page`, `design`, `settings`, `translations`, `access_mode`, `ai_context`. These name the parts of the survey, which is mostly also how `survey_edit` names its sections — the two exceptions are `questions`, changed there through `question_operations`, and `logic_rules`, through `logic_rule_operations`. Empty when nothing was discarded.
- question_count_before integerrequired
How many questions the discarded working draft held. This is a snapshot size, not a count of changes; equality with `question_count_after` does not mean that no questions were added, removed, or modified.
- question_count_after integerrequired
How many questions the survey has now, from the published version. This is a snapshot size, not a change indicator; inspect the question change lists even when the before and after counts are equal.
- questions_added_in_draft array of objectrequired
Questions that existed only in the draft. They are gone; their wording is reported so it can be re-entered.
- question_id string (nullable)required
- question stringrequired
- questions_removed_in_draft array of objectrequired
Questions the draft had deleted. They are back, with the answers they had already collected.
- question_id string (nullable)required
- question stringrequired
- questions_modified_in_draft array of objectrequired
Questions that exist on both sides but differ. The wording reported is the draft's — the version being discarded.
- question_id string (nullable)required
- question stringrequired
- sections array of stringrequired
- warnings array of string
- id string (uuid)required
Example response
{
"ok": true,
"data": {
"id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"discarded": true,
"discarded_changes": {
"sections": [
"metadata",
"questions"
],
"question_count_before": 6,
"question_count_after": 5,
"questions_added_in_draft": [
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"question": "How likely are you to recommend us?"
}
],
"questions_removed_in_draft": [],
"questions_modified_in_draft": [
{
"question_id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"question": "Which colour do you prefer?"
}
]
}
}
}post/surveys/{survey_id}/duplicate
Duplicate survey
Create a copy of an existing survey (unpublished draft). Owner only: the copy is a new survey owned by the caller, so it leaves the source survey's collaboration entirely. Question, option, matrix-axis and logic-rule IDs are regenerated, and logic references are remapped to those new IDs. The copy has no publication schedule. Counts against the same creation quota as survey_create (10 per hour, 50 per day per account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID to duplicate |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `survey_duplicate`. `id` is the new draft survey's UUID. The copy is always an unpublished draft, so `public_url` only becomes functional after publishing — use `preview_url` to look at it before then. It has no publication schedule and carries new question, option, matrix-axis and logic-rule IDs, with logic references remapped to those new IDs.
- id string (uuid)
Survey ID (UUID)
- public_url string
- public_url_note string
- preview_url string
Preview URL showing the current working draft. Responses submitted via this link are not counted.
- created object
Every new question and option identifier in the copy. Use these IDs for later edits without a follow-up survey_get.
- questions array of objectrequired
- question_id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- options array of object
- option_id stringrequired
- field "options" | "matrix_rows" | "matrix_columns"required
- question_id stringrequired
- questions array of objectrequired
- id string (uuid)
Example response
{
"ok": true,
"data": {
"id": "d4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70",
"public_url": "https://www.empirio.ai/s/d4e5f6a7-8b9c-4d0e-9f1a-2b3c4d5e6f70",
"public_url_note": "Survey is not published yet. This link will only work after publishing.",
"preview_url": "https://www.empirio.ai/p/7c1e5a90-3b42-4d8f-9a6b-1e2f3a4b5c6d"
}
}Responses
List, aggregate, export, cross-tabulate, and delete survey responses.
get/surveys/{survey_id}/responses
List responses
List unlocked survey participations newest first, each with a stable opaque response_id and a global newest-first row ranking (row_no) for pagination/deletion workflows. Supports order and an exclusive since watermark for polling clients. Returns full answers and response metadata without internal IDs.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| date_from | query | string | No | ISO 8601 date filter start |
| date_to | query | string | No | ISO 8601 date filter end |
| since | query | string | No | ISO 8601 date-time with a UTC offset (e.g. 2026-04-05T14:22:31.123456Z). Exclusive lower bound on the participation timestamp — the same field returned as `created_at` — so a polling client can pass the newest `created_at` it has already seen and receive only what arrived after it. Combines with `date_from`/`date_to` as an additional filter: the later of `since` and `date_from` wins, `date_to` still caps the upper end. |
| order | query | "asc" | "desc" | No | Emission order of the page. Default `desc` (newest first). `row_no` is always the newest-first rank regardless of this setting, so ranks stay valid for `responses_delete` under either order. `asc` cannot be combined with `include_incomplete_participations`. Prefer `desc` with `since` for polling: an ascending page derives its window from the current total, so a response submitted mid-request shifts it. |
| include_incomplete_participations | query | boolean | No | Include incomplete participations with meaningful saved progress |
| limit | query | integer | No | Default 100 |
| offset | query | integer | No | Zero-based pagination offset. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_list`.
- responses array of objectrequired
- response_id stringrequired
Stable opaque participation identifier (`resp_…` for submitted responses, `part_…` for incomplete participations). Derived from the participation, never a database key, and identical to the `response_id` the webhook payload carries — use it as the join key between polled rows and webhook deliveries, and as the dedupe key for polling triggers. Submitted responses and incomplete participations are separate ID spaces: a participation that is later submitted appears under a different `resp_…` id.
- row_no integer (nullable)required
Global newest-first row ranking (1-based), independent of `order`. Stable within a survey for pagination and accepted by `responses_delete`. `null` for incomplete participations when `since` is set, because their rank depends on how many submitted responses `since` filtered away and `responses_delete` cannot reproduce it.
- answers map of objectrequired
Flat record keyed by the `question_id` that existed when the answer was submitted. New submissions carrying any other key are rejected, but a later question deletion deliberately leaves the historical answer here and names its key in `orphaned_answer_question_ids`. Answer values are resolved to labels rather than internal option IDs, though an option deleted after the answer was recorded has no label left to resolve to and reports its raw identifier. A free-text 'other' answer is the typed text itself: inline as the value for a single-select, and as an extra entry in the array for a multiple-select. It has no separate key here — only the `answers` cell list in the webhook and JSON-export payloads carries it apart, as an `other_text` companion. Matrix answers are `{rowLabel: columnLabel}`, with duplicate row labels disambiguated as `Label (2)`, `Label (3)` … in `matrix_rows` definition order, so a given row keeps the same key across every response. A row deleted after submission appears as `Deleted row <row_id>`.
- orphaned_answer_question_ids array of stringrequired
Answer keys in this row that no longer match a question in the published survey, in stored key order. Empty when every answer still has a question. The values remain visible in `answers`; this field marks them so a join with `survey_get.published.questions` cannot silently drop them.
- created_at string (nullable)required
- completed_at string (nullable)required
- duration_seconds number (nullable)required
- ended_by_logic booleanrequired
- locale string (nullable)required
- participation_type "response" | "incomplete_participation"required
- response_id stringrequired
- total_count integerrequired
- has_more booleanrequired
- responses array of objectrequired
Example response
{
"ok": true,
"data": {
"responses": [
{
"response_id": "resp_8Kx2vQ1mR7pLtYbN4wZa3g",
"row_no": 1,
"answers": {
"q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42": "Dashboard",
"q_7b2f4e119c3a4a1d8e225f9d3b0a71e8": [
"Pricing",
"Support"
],
"q_3f8d4b217a954c6e89b12d5e7f1c9a34": 5,
"q_6e2c9a745b384f1d92e78a4c7b3d1f05": 9
},
"orphaned_answer_question_ids": [],
"created_at": "2026-04-05T14:22:31.000Z",
"completed_at": "2026-04-05T14:25:38.000Z",
"duration_seconds": 187,
"ended_by_logic": false,
"locale": "de-DE",
"participation_type": "response"
},
{
"response_id": "resp_5Db9Ht0cS6nWqXeJ2yVu1k",
"row_no": 2,
"answers": {
"q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42": "API",
"q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42_other": null,
"q_7b2f4e119c3a4a1d8e225f9d3b0a71e8": [
"Speed"
],
"q_3f8d4b217a954c6e89b12d5e7f1c9a34": 4,
"q_6e2c9a745b384f1d92e78a4c7b3d1f05": 7
},
"orphaned_answer_question_ids": [],
"created_at": "2026-04-05T15:10:02.000Z",
"completed_at": "2026-04-05T15:13:45.000Z",
"duration_seconds": 223,
"ended_by_logic": false,
"locale": "en-US",
"participation_type": "response"
}
],
"total_count": 142,
"has_more": true
}
}delete/surveys/{survey_id}/responses
Delete responses
Delete responses by stable response_ids (the resp_/part_ identifiers returned by responses_list, webhook deliveries and exports), by ranked row numbers from responses_list, or all responses for a survey. Owner only. An id that names no participation of this survey comes back in unresolved_response_ids, and one already deleted in already_deleted_response_ids, so the call is safe to retry. Ranked row numbers are a ranking over the surviving rows and move under concurrent deletion. Matching responses stop being returned by every read surface immediately, and this cannot be undone through the API.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
Required
- mode "ids" | "rows" | "all"required
Which responses to delete: 'ids' names them by the `response_id` a read surface returned (preferred), 'rows' by ranked row numbers from responses_list, 'all' deletes every response for the survey. Prefer 'ids': a rank is resolved against the live set at the moment of the call, so a response arriving between your list and your delete shifts every rank.
- response_ids array of string
`response_id` values from responses_list / responses_export when mode='ids'. Max 1000 per request. Each names one participation for its whole life, so the selection cannot shift between the read and the delete. Ids that no longer resolve are listed in `unresolved_response_ids` and the rest are still deleted; a request in which *no* id resolves is refused instead, naming them. Ids whose participation was already deleted come back in `already_deleted_response_ids` and answer success with `deleted_count: 0`, which is what makes a retry of this operation safe.
- row_numbers array of integer
Global response row numbers from responses_list (1-based) when mode='rows'. Max 1000 per request. Ranks are newest-first and unaffected by responses_list's `order` and `since`, which have no counterpart here. Racy by construction — prefer mode='ids'.
- date_from string
Date filter used for rank resolution (must match responses_list)
- date_to string
Date filter used for rank resolution (must match responses_list)
- include_incomplete_participations boolean
Incomplete participation filter used for rank resolution (must match responses_list)
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_delete`.
- deleted_count integerrequired
Number of responses that were soft-deleted.
- unresolved_row_numbers array of integerrequired
Row numbers from the request that could not be resolved to an existing response.
- unresolved_response_ids array of string
`mode: "ids"` only. Ids from the request that named no participation in this survey. A batch naming five ids and finding four deletes the four and says which one it could not place. A request in which no id at all resolves is refused rather than answered with an empty delete.
- already_deleted_response_ids array of string
`mode: "ids"` only. Ids that named a real participation which was already deleted. This is what a retry of a successful call reports, and it is why the id selector is safe to retry: the second call finds the same rows, deletes nothing, and answers `deleted_count: 0` instead of moving on to somebody else's data.
- deleted_count integerrequired
Example response
{
"ok": true,
"data": {
"deleted_count": 3,
"unresolved_row_numbers": [],
"unresolved_response_ids": [
"resp_qN4kS1vTgM8bXwLpZa7cRQ"
],
"already_deleted_response_ids": []
}
}get/surveys/{survey_id}/responses/aggregates
Get response aggregates
Get aggregate statistics for survey questions. Buckets are sparse: values with no responses are omitted instead of returned with count: 0. Percentages use respondents who answered the question (totalAnswered) as their denominator, so multi-select percentages can sum above 100%; totalSelections reports their selection count. NPS questions also include the computed score and promoter/passive/detractor groups.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| question_ids | query | array of string | No | Specific question IDs (omit for all, max 200) |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_aggregates`.
- aggregates objectrequired
Aggregate statistics produced by the analytics core. Field names use camelCase (`questionId`, `totalAnswered`, etc.) because this payload mirrors the analytics core's internal contract — the `questionId` value is the same opaque identifier other endpoints expose as `question_id`, so you can still use it as a join key.
- totalFiltered integerrequired
Total number of responses after filters, before per-question answer-counting.
- questions array of objectrequired
Per-question aggregates in the published survey's display order. Question ID suffixes do not encode position.
- questionId stringrequired
Stable question identifier. Same value as `question_id` emitted by `survey_get` and webhooks — camelCased here because this payload comes directly from the analytics core, which uses camelCase for its internal contract.
- questionText stringrequired
- questionType stringrequired
- totalAnswered integerrequired
Respondents who answered this question. On `multiple-choice`, `image-multiple-choice` and `ranking` — where one respondent contributes several bucket entries — this counts respondents, not selections; see `totalSelections` for the selection total.
- skipped integerrequired
Respondents in the filtered population (`totalFiltered`) who did not answer this question.
- totalSelections integer
Selections made across all respondents. Present only on the question types where one respondent can contribute more than one — `multiple-choice`, `image-multiple-choice`, `ranking` — where `sum(buckets[].count)` is this number rather than `totalAnswered`.
- nps object
Standard Net Promoter Score breakdown, present only for `nps` questions. Group percentages use valid 0–10 scores as their denominator: promoters 9–10, passives 7–8, detractors 0–6.
- score number (nullable)required
Promoter percentage minus detractor percentage (one decimal), or null when there is no valid 0–10 score.
- validResponses integerrequired
- unclassifiedResponses integerrequired
Answered values outside the valid integer range 0–10. They remain visible in buckets but are excluded from the NPS denominator.
- promoters objectrequired
…nested fields omitted
- passives objectrequired
…nested fields omitted
- detractors objectrequired
…nested fields omitted
- score number (nullable)required
- note string
Present when the aggregate cannot express something the question carries. `ranking` always sets it: a ranking answer contains every option exactly once, so the counts say who ranked at all and not what ranked where — `responses_export` has one column per rank position.
- buckets array of objectrequired
Sparse distribution of observed answers. Values with no responses are omitted rather than emitted with `count: 0`; use the published question definition when a complete configured option or scale domain is needed.
- value objectrequired
What was answered: a display label, a numeric value, or the text a respondent typed into an 'other' field. Option IDs are resolved to labels wherever a label still exists — an option deleted after the answer was recorded reports its raw identifier instead, because there is nothing left to resolve it against. One question's bucket list can therefore mix all three: do not assume a bucket value is a label.
- optionId string (nullable)required
The option this bucket stands for, for building `answer_filters` without guessing which label belongs to which option. `null` in the three cases where no single option feeds the bucket: free text typed into an 'other' field, an option deleted since the answer was recorded, and two options sharing one authored label — those merge into a single bucket, as they always have. Matrix questions report `null` throughout: a cell is a row/column pair, not an option.
- count integerrequired
- percentage numberrequired
Share of `totalAnswered` for this bucket (one decimal, e.g. 42.5) — that is, of the respondents who answered the question, not of everyone in the filtered population. The denominator is the same for every question type.
- value objectrequired
- rows array of string
Ordered matrix row labels; present for matrix questions.
- columns array of string
Ordered matrix column labels; present for matrix questions.
- matrixCounts map of map of integer
Matrix counts keyed first by row label and then by column label.
- questionId stringrequired
- orphanedQuestionIds array of stringrequired
Question IDs found in stored answers but absent from the published survey, deduplicated. Their buckets are not included in `questions` because no question definition remains to describe their type or labels.
- totalFiltered integerrequired
- unresolved_question_ids array of stringrequired
Requested `question_ids` that match no question in the **published** survey, deduplicated. Empty when every requested id resolved, and always empty when the request did not name any. Check this before reading a missing question as 'no answers': a mistyped id lands here instead. Note that responses are only ever recorded against the published survey, so a question that exists solely in the working draft is reported here too. `survey_get.published.questions` contains the questions responses can be filtered by, so take the ids from there.
- aggregates objectrequired
Example response
{
"ok": true,
"data": {
"aggregates": {
"totalFiltered": 142,
"orphanedQuestionIds": [],
"questions": [
{
"questionId": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"questionText": "Which product area do you use most?",
"questionType": "single-choice",
"totalAnswered": 142,
"skipped": 0,
"buckets": [
{
"value": "Dashboard",
"optionId": "opt_4f2a9c1b",
"count": 78,
"percentage": 54.9
},
{
"value": "Reports",
"optionId": "opt_8d31e07a",
"count": 41,
"percentage": 28.9
},
{
"value": "API",
"optionId": null,
"count": 23,
"percentage": 16.2
}
]
},
{
"questionId": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"questionText": "How would you rate our service?",
"questionType": "rating",
"totalAnswered": 142,
"skipped": 0,
"buckets": [
{
"value": 5,
"optionId": null,
"count": 71,
"percentage": 50
},
{
"value": 4,
"optionId": null,
"count": 48,
"percentage": 33.8
},
{
"value": 3,
"optionId": null,
"count": 15,
"percentage": 10.6
},
{
"value": 2,
"optionId": null,
"count": 6,
"percentage": 4.2
},
{
"value": 1,
"optionId": null,
"count": 2,
"percentage": 1.4
}
]
},
{
"questionId": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"questionText": "How likely are you to recommend us?",
"questionType": "nps",
"totalAnswered": 142,
"skipped": 0,
"nps": {
"score": 42.2,
"validResponses": 142,
"unclassifiedResponses": 0,
"promoters": {
"count": 86,
"percentage": 60.6
},
"passives": {
"count": 30,
"percentage": 21.1
},
"detractors": {
"count": 26,
"percentage": 18.3
}
},
"buckets": [
{
"value": 10,
"optionId": null,
"count": 48,
"percentage": 33.8
},
{
"value": 9,
"optionId": null,
"count": 38,
"percentage": 26.8
},
{
"value": 8,
"optionId": null,
"count": 18,
"percentage": 12.7
},
{
"value": 7,
"optionId": null,
"count": 12,
"percentage": 8.5
},
{
"value": 6,
"optionId": null,
"count": 10,
"percentage": 7
},
{
"value": 5,
"optionId": null,
"count": 8,
"percentage": 5.6
},
{
"value": 4,
"optionId": null,
"count": 8,
"percentage": 5.6
}
]
},
{
"questionId": "q_1a2b3c445d664e778f889a0b1c2d3e4f",
"questionText": "How do you rate each area?",
"questionType": "matrix",
"totalAnswered": 3,
"skipped": 0,
"rows": [
"Speed",
"Quality"
],
"columns": [
"Good",
"Great"
],
"matrixCounts": {
"Speed": {
"Good": 2,
"Great": 1
},
"Quality": {
"Good": 1,
"Great": 2
}
},
"buckets": [
{
"value": {
"row": "Speed",
"column": "Good"
},
"optionId": null,
"count": 2,
"percentage": 66.7
},
{
"value": {
"row": "Speed",
"column": "Great"
},
"optionId": null,
"count": 1,
"percentage": 33.3
},
{
"value": {
"row": "Quality",
"column": "Good"
},
"optionId": null,
"count": 1,
"percentage": 33.3
},
{
"value": {
"row": "Quality",
"column": "Great"
},
"optionId": null,
"count": 2,
"percentage": 66.7
}
]
}
]
},
"unresolved_question_ids": []
}
}get/surveys/{survey_id}/responses/crosstab
Cross-tabulate responses
Cross-tabulate answers between two non-matrix survey questions. Matrix questions are rejected because the two-axis contract cannot select an individual matrix row.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| question_x | query | string | Yes | First non-matrix question ID (rows) |
| question_y | query | string | Yes | Second non-matrix question ID (columns) |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_crosstab`.
- crosstab objectrequired
Cross-tabulation between the two requested non-matrix questions. Field names are camelCase because this payload mirrors the analytics core's internal contract; row/column labels are already label-resolved.
- rowQuestion objectrequired
Row question definition. `id` is the same stable question identifier as `question_id` elsewhere.
- id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- text stringrequired
- id stringrequired
- colQuestion objectrequired
Column question definition.
- id stringrequired
Stable opaque question identifier. Same value emitted by webhooks and returned by survey_get — safe to use as a join key across REST and webhook consumers.
- text stringrequired
- id stringrequired
- matrix array of objectrequired
- rowValue stringrequired
Row label.
- rowTotal integerrequired
- columns array of objectrequired
- colValue stringrequired
Column label for this cell.
- count integerrequired
- rowPercentage numberrequired
Row-normalised percentage (one decimal, e.g. 66.7).
- colValue stringrequired
- rowValue stringrequired
- truncated booleanrequired
True when the row/column set was truncated to stay under the max-categories limit.
- rowQuestion objectrequired
- crosstab objectrequired
Example response
{
"ok": true,
"data": {
"crosstab": {
"rowQuestion": {
"id": "q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42",
"text": "Which product area do you use most?"
},
"colQuestion": {
"id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"text": "How would you rate our service?"
},
"matrix": [
{
"rowValue": "Dashboard",
"rowTotal": 78,
"columns": [
{
"colValue": "1",
"count": 0,
"rowPercentage": 0
},
{
"colValue": "2",
"count": 2,
"rowPercentage": 2.6
},
{
"colValue": "3",
"count": 3,
"rowPercentage": 3.8
},
{
"colValue": "4",
"count": 18,
"rowPercentage": 23.1
},
{
"colValue": "5",
"count": 55,
"rowPercentage": 70.5
}
]
},
{
"rowValue": "Reports",
"rowTotal": 41,
"columns": [
{
"colValue": "1",
"count": 1,
"rowPercentage": 2.4
},
{
"colValue": "2",
"count": 2,
"rowPercentage": 4.9
},
{
"colValue": "3",
"count": 6,
"rowPercentage": 14.6
},
{
"colValue": "4",
"count": 15,
"rowPercentage": 36.6
},
{
"colValue": "5",
"count": 17,
"rowPercentage": 41.5
}
]
},
{
"rowValue": "API",
"rowTotal": 23,
"columns": [
{
"colValue": "1",
"count": 1,
"rowPercentage": 4.3
},
{
"colValue": "2",
"count": 2,
"rowPercentage": 8.7
},
{
"colValue": "3",
"count": 6,
"rowPercentage": 26.1
},
{
"colValue": "4",
"count": 6,
"rowPercentage": 26.1
},
{
"colValue": "5",
"count": 8,
"rowPercentage": 34.8
}
]
}
],
"truncated": false
}
}
}get/surveys/{survey_id}/responses/stats
Get survey statistics
Survey-level statistics: view count, started and completed responses, completion rate, and completion time (average, shortest, longest, median). Field names are camelCase because the payload mirrors the analytics core.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_survey_stats`. Counts reflect the survey's session dashboard and are computed by the analytics edge function. Field names are camelCase because this payload mirrors the analytics core's internal contract, the same way `responses_aggregates` does.
- surveyId string (uuid)
Survey ID (UUID)
- viewCount integer
- startedResponses integer
- completedResponses integer
- completionRate number
Completed divided by started, as a percentage.
- duration object
Completion time in seconds across completed responses.
- average numberrequired
- shortest numberrequired
- longest numberrequired
- median numberrequired
- surveyId string (uuid)
Example response
{
"ok": true,
"data": {
"surveyId": "3f8d4b21-7a95-4c6e-89b1-2d5e7f1c9a34",
"viewCount": 412,
"startedResponses": 198,
"completedResponses": 142,
"completionRate": 71.7,
"duration": {
"average": 192,
"shortest": 48,
"longest": 903,
"median": 174
}
}
}post/surveys/{survey_id}/responses/export/responses
Export responses
Export unlocked survey responses as CSV, XLSX, JSON, or SPSS. Returns a temporary signed download URL and the actual file extension; SPSS is delivered as a ZIP bundle. By default only completed participations are exported unless include_incomplete_participations=true.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
- format "csv" | "xlsx" | "json" | "spss"
Export format (default: csv)
- date_from string
ISO 8601 date filter start
- date_to string
ISO 8601 date filter end
- include_incomplete_participations boolean
Include incomplete participations (default: false = completed only).
- time_zone string
IANA timezone for exported date/time fields (e.g. Europe/Berlin).
- answer_filters array of object
Answer filters (max 100) with the same semantics as the frontend results filters.
- question_id stringrequired
Question ID to filter by
- answer objectrequired
Answer value to match (scalar or array depending on question type)
- question_id stringrequired
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `responses_export`.
- format "csv" | "xlsx" | "json" | "spss"required
- file_extension "csv" | "xlsx" | "json" | "zip"required
Extension of the downloaded artifact without a leading dot. Usually the same as `format`; `format: "spss"` returns a ZIP bundle and therefore reports `zip`.
- row_count integerrequired
Number of response rows in the exported file. Answers "did my filter match anything" without downloading it — the number reflects `date_from`/`date_to`, `answer_filters` and `include_incomplete_participations`. `0` means the filter matched nothing.
- download_url stringrequired
Temporary signed download URL (valid for ~1 hour).
- expires_at stringrequired
ISO timestamp when the signed download URL expires. Re-issued on every answer, including an idempotent replay, so it is never in the past.
Example response
{
"ok": true,
"data": {
"format": "xlsx",
"file_extension": "xlsx",
"row_count": 128,
"download_url": "https://storage.empirio.ai/exports/9f3a8b12.../responses-1712345678.xlsx?token=...",
"expires_at": "2026-04-10T17:45:00.000Z"
}
}post/surveys/{survey_id}/responses/export/charts
Export charts
Export unlocked survey aggregate charts in one of six formats: native editable PowerPoint (pptx), image-based PowerPoint (pptx_images), landscape A4 PDF (pdf), Word document (docx), ZIP of PNG charts (zip_images), or structured chart data (chart_json). For binary formats, returns a temporary signed download URL (valid for 1 hour) and the actual file_extension. For chart_json, returns structured chart data inline; configured option and bounded-scale axes include unused values with count: 0. By default only completed participations are included unless include_incomplete_participations=true.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
- format "pptx" | "pptx_images" | "pdf" | "docx" | "zip_images" | "chart_json"
Chart export format (default: pptx). 'pptx' = native editable PowerPoint, 'pptx_images' = PowerPoint with raster slides, 'pdf' = landscape A4 PDF, 'docx' = Word document, 'zip_images' = ZIP archive of PNG charts, 'chart_json' = structured chart data.
- date_from string
ISO 8601 date filter start
- date_to string
ISO 8601 date filter end
- include_incomplete_participations boolean
Include incomplete participations in chart export (default: false = completed only).
- answer_filters array of object
Answer filters (max 100) with the same semantics as the frontend results filters.
- question_id stringrequired
Question ID to filter by
- answer objectrequired
Answer value to match (scalar or array depending on question type)
- question_id stringrequired
- time_zone string
Reserved parity field for timezone-aware chart export pipelines.
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data object | objectrequired
Response payload for `responses_export_charts`. Chart exports do not return `row_count`; a request with no chartable answers returns `no_chartable_data`.
Example response
{
"ok": true,
"data": {
"format": "pptx",
"file_extension": "pptx",
"download_url": "https://storage.empirio.ai/exports/9f3a8b12.../charts-1712345678.pptx?token=...",
"expires_at": "2026-04-10T17:45:00.000Z"
}
}Webhooks
Overview
empirio.ai can push survey responses to your server as soon as they are submitted. Webhooks complement the REST API — you register endpoints once, and empirio delivers signed JSON payloads every time a respondent finishes a survey.
| Property | Value |
|---|---|
| Direction | Outbound (empirio → your server) |
| Trigger | survey.response.submitted |
| Transport | HTTPS only (private IP ranges rejected) |
| Signing | HMAC-SHA256 over <timestamp>.<raw request body> |
| Content type | application/json |
| Max endpoints per survey | 10 |
| Retries | 3 attempts in total; retries after about 1 and 10 minutes with jitter, respecting Retry-After |
Endpoints are managed in Settings → Integrations in the empirio app or over the REST API — see Endpoints in the sidebar. The signing secret is returned only once on creation — store it immediately.
Endpoints
Register, inspect, and remove delivery endpoints over the REST API. These endpoints use the same Bearer authentication, rate limits and Idempotency-Key header as the rest of the REST API.
They require both the webhooks and responses scopes, and are not reachable with any other grant. webhooks is a permission of its own because registering an endpoint sends response data out of the account continuously, to a destination the caller picks; responses is required alongside it because the endpoint receives response content, so a token that cannot read responses cannot arrange for them to be delivered elsewhere either.
They are also not exposed as CLI commands (x-cli-hidden) or as MCP tools. Registering a delivery endpoint is a durable account configuration for an automation platform, not something to type at a terminal or hand to an agent mid-conversation — and the signing secret is returned exactly once.
| Method | Path | Purpose |
|---|---|---|
GET | /surveys/{survey_id}/webhooks | List endpoints on a survey |
POST | /surveys/{survey_id}/webhooks | Register an endpoint |
PATCH | /webhooks/{webhook_id} | Change URL, label, or delivery state |
DELETE | /webhooks/{webhook_id} | Remove an endpoint |
POST | /webhooks/{webhook_id}/rotate-secret | Issue a new signing secret |
POST | /webhooks/{webhook_id}/deliveries/{delivery_id}/resolve | Resolve a Make notification to its stored payload |
Idempotent registration
POST /surveys/{survey_id}/webhooks is idempotent on endpoint_url:
- New endpoint → 201 with
already_exists: falseand the signingsecret(64 hex characters), returned this one time only. - URL already registered on the survey → 200 with
already_exists: true, the existing configuration, andsecret: null. Lost secrets are replaced viaPOST /webhooks/{webhook_id}/rotate-secret, never re-revealed.
Automation platforms can therefore run "create the endpoint if it does not exist" on every workflow activation without special-casing conflicts.
Labels and ownership
label (max 120 characters) is descriptive. managed_by (zapier | make | n8n | custom) identifies the registering client; make additionally enables the delivery-proof and authenticated fetch-back transport. Both are returned on every webhook object and are null when unset. An idempotent registration with an explicit managed_by value adopts the existing endpoint for that client and resumes it when paused.
Limits
A survey accepts up to 10 endpoints. Registering an eleventh returns 400 with validation_error. Endpoints must be HTTPS and resolve to a public address; private, link-local, and loopback ranges are rejected.
get/surveys/{survey_id}/webhooks
List webhook endpoints
List the webhook endpoints registered on a survey. Signing secrets are never returned.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `webhook_list`.
- webhooks array of objectrequired
Endpoints registered on this survey.
- id string (uuid)required
Webhook ID.
- survey_id string (uuid)required
Survey this endpoint belongs to.
- endpoint_url stringrequired
HTTPS endpoint that receives deliveries.
- label string (nullable)required
Human-readable name, or null when unset.
- managed_by "zapier" | "make" | "n8n" | "custom" (nullable)required
Tool that registered the endpoint, or null when unset.
- is_active booleanrequired
False while deliveries are paused.
- created_at stringrequired
ISO-8601 creation timestamp.
- updated_at stringrequired
ISO-8601 timestamp of the last change.
- id string (uuid)required
- webhooks array of objectrequired
Example response
{
"ok": true,
"data": {
"webhooks": [
{
"id": "6a1f2c84-9b3d-4e57-8c21-7f0a5d3b9e64",
"survey_id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"endpoint_url": "https://hooks.example.com/empirio",
"label": "Production CRM sync",
"managed_by": "n8n",
"is_active": true,
"created_at": "2026-08-01T09:14:22.000Z",
"updated_at": "2026-08-01T09:14:22.000Z"
}
]
}
}post/surveys/{survey_id}/webhooks
Create webhook endpoint
Register an HTTPS endpoint that receives a signed JSON payload whenever a response is submitted to this survey. A survey accepts up to 10 endpoints.
The call is idempotent on the endpoint URL: registering a URL that already exists on the survey returns the existing configuration with already_exists: true and secret: null (HTTP 200) instead of an error. A newly created endpoint answers HTTP 201 and returns the signing secret exactly once — store it immediately; use rotate-secret if it is lost.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| survey_id | path | string (uuid) | Yes | Survey ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
Required
- endpoint_url string (uri)required
HTTPS endpoint that receives the signed delivery. Private/internal hosts are rejected.
- label string
Optional human-readable name for this endpoint (max 120 characters).
- managed_by "zapier" | "make" | "n8n" | "custom"
Which tool registered this endpoint. It identifies automation-created endpoints on the Integrations page; the make value also enables the authenticated delivery fetch-back flow.
Example — Registers an HTTPS endpoint that receives every submitted response.
{
"endpoint_url": "https://hooks.example.com/empirio",
"label": "Production CRM sync"
}Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 201 | Resource created |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `webhook_create`.
- webhook objectrequired
Webhook endpoint configuration.
- id string (uuid)required
Webhook ID.
- survey_id string (uuid)required
Survey this endpoint belongs to.
- endpoint_url stringrequired
HTTPS endpoint that receives deliveries.
- label string (nullable)required
Human-readable name, or null when unset.
- managed_by "zapier" | "make" | "n8n" | "custom" (nullable)required
Tool that registered the endpoint, or null when unset.
- is_active booleanrequired
False while deliveries are paused.
- created_at stringrequired
ISO-8601 creation timestamp.
- updated_at stringrequired
ISO-8601 timestamp of the last change.
- id string (uuid)required
- secret string (nullable)required
Signing secret (64 hex characters), returned only when the endpoint was newly created. Null when `already_exists` is true — use `rotate-secret` to obtain a new secret.
- already_exists booleanrequired
True when this endpoint URL was already registered on the survey and the existing configuration was returned (HTTP 200 instead of 201). A paused endpoint is resumed before it is returned, so a successful call always means deliveries are active.
- secret_withheld boolean
True when this response is a replay of an earlier request with the same `Idempotency-Key`. The signing secret is revealed once, to the request that created or rotated it, and is not stored for replay — so `secret` is null here. Call `rotate-secret` to obtain a usable secret.
- endpoint_url_redacted boolean
True when this response is a replay of an earlier request with the same `Idempotency-Key`. The endpoint URL is not kept for replay — for Zapier catch hooks, Slack and Discord the URL is itself the credential — so `webhook.endpoint_url` is reduced to `scheme://host/#redacted` here and must not be stored as the real endpoint. Call `webhook_list` to read the current configuration.
- webhook objectrequired
Example response
{
"ok": true,
"data": {
"webhook": {
"id": "6a1f2c84-9b3d-4e57-8c21-7f0a5d3b9e64",
"survey_id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"endpoint_url": "https://hooks.example.com/empirio",
"label": "Production CRM sync",
"managed_by": "n8n",
"is_active": true,
"created_at": "2026-08-01T09:14:22.000Z",
"updated_at": "2026-08-01T09:14:22.000Z"
},
"secret": "9f2c1d7b4a63e58f0c3d8b1a6e4f92d70b5c8a3e1f6d4b029c7a5e83f1d604b2",
"already_exists": false
}
}patch/webhooks/{webhook_id}
Update webhook endpoint
Update a webhook endpoint's URL, label, or delivery state. Pausing an endpoint (is_active: false) keeps its configuration and signing secret intact.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| webhook_id | path | string (uuid) | Yes | Webhook ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
- endpoint_url string (uri)
HTTPS endpoint that receives the signed delivery. Private/internal hosts are rejected.
- is_active boolean
Pause (false) or resume (true) deliveries to this endpoint.
- label string
Optional human-readable name for this endpoint (max 120 characters).
Example — Stops deliveries without removing the endpoint or its signing secret.
{
"is_active": false
}Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `webhook_update`.
- webhook objectrequired
Webhook endpoint configuration.
- id string (uuid)required
Webhook ID.
- survey_id string (uuid)required
Survey this endpoint belongs to.
- endpoint_url stringrequired
HTTPS endpoint that receives deliveries.
- label string (nullable)required
Human-readable name, or null when unset.
- managed_by "zapier" | "make" | "n8n" | "custom" (nullable)required
Tool that registered the endpoint, or null when unset.
- is_active booleanrequired
False while deliveries are paused.
- created_at stringrequired
ISO-8601 creation timestamp.
- updated_at stringrequired
ISO-8601 timestamp of the last change.
- id string (uuid)required
- endpoint_url_redacted boolean
True when this response is a replay of an earlier request with the same `Idempotency-Key`. The endpoint URL is not kept for replay — for Zapier catch hooks, Slack and Discord the URL is itself the credential — so `webhook.endpoint_url` is reduced to `scheme://host/#redacted` here and must not be stored as the real endpoint. Call `webhook_list` to read the current configuration.
- webhook objectrequired
Example response
{
"ok": true,
"data": {
"webhook": {
"id": "6a1f2c84-9b3d-4e57-8c21-7f0a5d3b9e64",
"survey_id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"endpoint_url": "https://hooks.example.com/empirio",
"label": "Production CRM sync",
"managed_by": "n8n",
"is_active": false,
"created_at": "2026-08-01T09:14:22.000Z",
"updated_at": "2026-08-01T09:14:22.000Z"
}
}
}delete/webhooks/{webhook_id}
Delete webhook endpoint
Permanently remove a webhook endpoint. Pending deliveries for the endpoint are dropped. Use PATCH /webhooks/{webhook_id} with is_active: false to pause deliveries instead.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| webhook_id | path | string (uuid) | Yes | Webhook ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `webhook_delete`.
- deleted truerequired
Always true when the endpoint was removed.
- deleted truerequired
Example response
{
"ok": true,
"data": {
"deleted": true
}
}post/webhooks/{webhook_id}/rotate-secret
Rotate webhook signing secret
Generate a new signing secret for a webhook endpoint and return it once. The previous secret stops verifying immediately, so update your receiver before rotating.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| webhook_id | path | string (uuid) | Yes | Webhook ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Response payload for `webhook_rotate_secret`.
- webhook objectrequired
Webhook endpoint configuration.
- id string (uuid)required
Webhook ID.
- survey_id string (uuid)required
Survey this endpoint belongs to.
- endpoint_url stringrequired
HTTPS endpoint that receives deliveries.
- label string (nullable)required
Human-readable name, or null when unset.
- managed_by "zapier" | "make" | "n8n" | "custom" (nullable)required
Tool that registered the endpoint, or null when unset.
- is_active booleanrequired
False while deliveries are paused.
- created_at stringrequired
ISO-8601 creation timestamp.
- updated_at stringrequired
ISO-8601 timestamp of the last change.
- id string (uuid)required
- secret string (nullable)required
The new signing secret (64 hex characters). Null only when this response is a replay of an earlier request with the same `Idempotency-Key` — see `secret_withheld`.
- secret_withheld boolean
True when this response is a replay of an earlier request with the same `Idempotency-Key`. The signing secret is revealed once, to the request that created or rotated it, and is not stored for replay — so `secret` is null here. Call `rotate-secret` to obtain a usable secret.
- endpoint_url_redacted boolean
True when this response is a replay of an earlier request with the same `Idempotency-Key`. The endpoint URL is not kept for replay — for Zapier catch hooks, Slack and Discord the URL is itself the credential — so `webhook.endpoint_url` is reduced to `scheme://host/#redacted` here and must not be stored as the real endpoint. Call `webhook_list` to read the current configuration.
- webhook objectrequired
Example response
{
"ok": true,
"data": {
"webhook": {
"id": "6a1f2c84-9b3d-4e57-8c21-7f0a5d3b9e64",
"survey_id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"endpoint_url": "https://hooks.example.com/empirio",
"label": "Production CRM sync",
"managed_by": "n8n",
"is_active": true,
"created_at": "2026-08-01T09:14:22.000Z",
"updated_at": "2026-08-01T09:14:22.000Z"
},
"secret": "3b8e1f70d24c9a65e0f7b3d81c4a26f9508d7e3b1a6c4f92d05b8e731f4c6a20"
}
}post/webhooks/{webhook_id}/deliveries/{delivery_id}/resolve
Resolve a Make webhook delivery
Exchange a delivery-bound Make proof for the authoritative payload saved before the webhook was sent. The authenticated caller must own the Make-managed webhook, and the delivery must belong to it. The exact proof is valid only while that delivery row is retained. This operation exists only for the Make instant trigger.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| webhook_id | path | string (uuid) | Yes | Make-managed webhook ID. |
| delivery_id | path | string (uuid) | Yes | Webhook delivery job ID. |
| Idempotency-Key | header | string | Yes | Required idempotency key for write operations. Reuse the same value when retrying the same request. |
Request body
Required
- delivery_proof stringrequired
Opaque X-EmpirioAi-Delivery-Proof value received for this Make delivery.
Responses
| Status | Description |
|---|---|
| 200 | Successful response |
| 400 | Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`. |
| 401 | Unauthorized. `error.code` is one of: `not_authorized`. |
| 403 | Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`. |
| 404 | Not found. `error.code` is one of: `not_found`, `export_expired`. |
| 409 | Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`. |
| 429 | Rate limited. `error.code` is one of: `rate_limited`. |
| 500 | Internal error. `error.code` is one of: `edge_error`, `export_error`, `export_timeout`, `upload_error`, `query_error`, `response_counts_unavailable`, `idempotency_finalize_error`, `internal_error`. |
200 response schema
- data objectrequired
Verified payload returned to a Make instant trigger.
- event "survey.response.submitted"required
- delivery_id string (uuid)required
- survey_id string (uuid)required
- survey_title stringrequired
- survey_version integer (nullable)required
- responded_at string (date-time)required
- response objectrequired
- response_id stringrequired
- duration_seconds number (nullable)required
- response_locale string (nullable)required
- ended_by_logic booleanrequired
- answers array of map of objectrequired
- answers_flat map of objectrequired
- answers_by_title map of objectrequired
Example response
{
"ok": true,
"data": {
"event": "survey.response.submitted",
"delivery_id": "b7c1e5a9-3b42-4d8f-9a6b-1e2f3a4b5c6d",
"survey_id": "9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b",
"survey_title": "Customer satisfaction Q1",
"survey_version": 7,
"responded_at": "2026-04-05T14:22:31.000Z",
"response": {
"response_id": "resp_9f3a1c7d2b4e",
"duration_seconds": 187,
"response_locale": "en-US",
"ended_by_logic": false,
"answers": [],
"answers_flat": {
"q_a3f8d1b24e5c4c2a9d110f8b7e6a1c42": "Social media"
},
"answers_by_title": {
"How did you hear about us?": "Social media"
}
}
}
}Signing & Verification
Request headers
Every delivery arrives with these headers:
Content-Type: application/json
X-EmpirioAi-Signature: t=<unix seconds>,v1=<hex>
X-EmpirioAi-Timestamp: <unix seconds>
X-EmpirioAi-Survey-Id: <uuid>
X-EmpirioAi-Delivery-Id: <uuid>
User-Agent: empirio-webhook/1.0
Verifying the signature
X-EmpirioAi-Signature carries t=<unix seconds>,v1=<hex>. The digest is an HMAC-SHA256 over <t>.<raw request body> using your endpoint's signing secret. Always verify before trusting the payload.
The timestamp is part of the signed material rather than a separate header, and that is the point: a digest over the body alone would make a captured delivery replayable forever, because nothing in it says when it was sent. Reject anything outside a tolerance you choose — five minutes is a reasonable default — and reject timestamps from the future as well, since a request dated ahead is as suspect as a stale one.
Parse the header by key rather than by position, and ignore vN= parts you do not recognise: a future scheme will be added alongside v1 rather than replacing it.
import crypto from "node:crypto";
const MAX_AGE_SECONDS = 5 * 60;
function verifyEmpirioSignature(rawBody, headerValue, secret) {
const parts = Object.fromEntries(
String(headerValue || "")
.split(",")
.map((part) => {
const at = part.indexOf("=");
return at === -1 ? [] : [part.slice(0, at).trim(), part.slice(at + 1).trim()];
})
.filter((entry) => entry.length === 2),
);
const timestamp = parts.t;
const received = (parts.v1 || "").toLowerCase();
if (!/^[0-9]+$/.test(timestamp || "") || !/^[0-9a-f]{64}$/.test(received)) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > MAX_AGE_SECONDS || age < -MAX_AGE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
}
Payload Structure
Envelope
{
"event": "survey.response.submitted",
"delivery_id": "…",
"survey_id": "…",
"survey_title": "Customer Satisfaction Q1",
"survey_version": 7,
"responded_at": "2026-04-05T14:22:31.000Z",
"response": {
"response_id": "resp_…",
"duration_seconds": 187,
"response_locale": "de-DE",
"ended_by_logic": false,
"answers": [ /* cell entries — see below */ ],
"answers_flat": { /* question_id → answer — see below */ },
"answers_by_title": { /* question title → answer — see below */ }
}
}
Identifiers
delivery_id identifies this delivery attempt and carries the same value as the X-EmpirioAi-Delivery-Id header. It stays constant across retries of the same delivery, so it is the key to deduplicate on.
response.response_id identifies the participation itself. It is opaque and stable, and GET /surveys/{survey_id}/responses returns the same value for the same participation — so webhook receivers and REST clients share one join key. Treat it as a string; do not parse it.
question_id is likewise an opaque string. Different creation paths mint different shapes — q_text, q_<hex> and others all occur — so match on equality, never parse or pattern-match it. The examples below use one arbitrary shape; do not read a format into it.
Answer entries
Each element of response.answers represents one "cell", using the same decomposition as empirio's CSV export — one entry per scalar answer, per multi-select option, per matrix row, per rank position, and per allow_other free-text companion. The two are not identical in width: see Which parts of the cell list are fixed below, since a ranking contributes one entry per stored rank while CSV pads every row to the survey's widest.
Every entry includes question_id, question_title, and question_type. The rest of the shape depends on what kind of question the entry belongs to — entries are distinguished by field presence, not an explicit discriminator:
| Emitted for | Extra fields | value | Identify by |
|---|---|---|---|
| text, text-long, email, phone, date, number, rating, scale, nps, thumbs, yes-no, privacy, single-choice, dropdown, image-single-choice, text-rating | selected_option_id (single-select option types only) | string / number / boolean / null | none of option_label / row_label / rank_position / other_text present — selected_option_id may or may not be there, and either way the entry is a scalar / single-select one |
| multiple-choice, image-multiple-choice (one entry per defined option) | option_id, option_label | boolean (true if selected) | option_label present |
| matrix (one entry per defined row, plus retained deleted-row answers when present) | row_id, row_label, selected_column_id | string / null (column label) | row_label present |
| ranking (one entry per rank position; N = the larger of the defined option count and this response's stored ranking) | rank_position, selected_option_id | string / null (option label at this position) | rank_position present |
any question with allow_other: true (free-text companion) | other_text (replaces value) | — | other_text present |
Note: a single-choice, multiple-choice, or dropdown question with allow_other: true emits its normal entries plus one trailing companion entry in the same response — so a single single-choice question can contribute two entries, and a multiple-choice question with N options contributes N+1.
A consumer can route entries with a single switch:
for (const entry of response.answers) {
if ("other_text" in entry) { /* free-text "other" answer */ }
else if ("option_label" in entry) { /* multiple-choice one-hot */ }
else if ("row_label" in entry) { /* matrix row */ }
else if ("rank_position" in entry){ /* ranking position */ }
else { /* scalar / single-select */ }
}
Flat answers
response.answers_flat carries the same response keyed by question_id, one entry per question rather than one per cell:
"answers_flat": {
"q_a3f8d1b2-…": "Blue", // single-select / scalar
"q_7b2f4e11-…": ["Pricing", "Custom other"], // multiple-choice — selected labels, free text inline
"q_3f8d4b21-…": { "Quality": "Excellent" }, // matrix — answered rows, row → column
"q_6e2c9a74-…": ["C", "A", "B"] // ranking — best first
}
Every key is a question_id. There are no derived keys — in particular there is no <question_id>_other entry, because a submission whose answer keys are not question IDs is rejected outright. A free-text "other" answer reaches you two ways instead: inline in the question's own answers_flat value (the free text stands in for the option label), and as the dedicated other_text companion cell in answers. The companion cell is the reliable one — it is the only place the free text appears on its own, separate from the regular selections.
It is the same record GET /surveys/{survey_id}/responses returns as answers, so the two surfaces agree field for field. Use it when you want one named value per question.
response.answers_by_title carries those same values under the question's title instead of its ID:
"answers_by_title": {
"How did you hear about us?": "Social media",
"Which features matter?": ["Pricing", "Custom other"]
}
It exists for visual builders, where the key of a mapped field IS its label and a column of q_… keys cannot be mapped by hand. Values are taken verbatim from answers_flat; the titles and the question order come from the cell list. Two questions sharing a title are disambiguated with (2), (3) in survey order, a free-text "other" answer arrives under <title> (Other), an untitled question keeps its question_id as the key, and so does an answer to a question that has since been deleted, because the cell list no longer describes it. Titles are author-written and change; answers_flat is the one to key on when a stored mapping has to survive a rename.
The two views answer different questions, so pick deliberately. answers_flat reflects what was stored: a question the respondent skipped, an unselected multiple-choice option, and an unanswered matrix row are simply absent, and an answer to a question that has since been deleted is still there under its stored key. answers instead follows the current survey definition: it emits a cell for every defined option, row and rank position, so an unselected option and an unanswered row still produce an entry.
How much of answers is fixed
Most of the cell list is a property of the survey, and two parts of it are a property of the individual response. Both exceptions exist because a stored answer can outlive the definition it was written against:
| Question type | Entries | Fixed per survey? |
|---|---|---|
| text, number, date, single-select, … | 1 | yes |
| multiple-choice | one per currently defined option | yes |
| matrix | one per currently defined row | yes |
| matrix, retained deleted row | one per row key this response still answers that the question no longer defines | no — per response |
| ranking | max(currently defined options, this response's stored ranking length) | no — per response |
other_text companion | 1 per allow_other question | yes |
A ranking is frozen at submission time, so deleting one option leaves every earlier answer one rank longer than the current option list. Within a single export that means a response which ranked five items contributes five ranking cells while a response submitted after the deletion contributes four. A deleted matrix row behaves the same way: the extra "Deleted row row_…" entry appears only on the responses that actually still hold an answer for it.
So iterate the array and key on question_id plus the distinguishing field (option_id, row_id, rank_position). Do not address a cell by its index, and do not assume two responses of the same survey have the same number of cells.
Writing rows to a spreadsheet? Use the CSV or XLSX export instead of building rows from answers. Those exports compute one column plan for the whole file — the widest stored ranking and the union of deleted matrix rows across every exported response — so every row is the same width and the header matches all of them. answers is built per response and has no such shared plan.
Value conventions
- No localization. Booleans are real JSON
true/false(including yes-no and privacy); localize for presentation on your side. question_idis stable, persistent, and opaque. Each question is assigned an ID at creation time and keeps it for the survey's lifetime. Inserting, removing, or reordering questions does not shift existing IDs, soquestion_idis a sound join key for longitudinal or repeated-response analysis. The format is not a contract: different creation paths mint different shapes (q_text,q_<hex>, and others). Compare it for equality; never parse it.- IDs travel alongside labels. A label is what a survey author edits, so a mapping built on one breaks silently when someone renames an option. Every entry that has an option identity reports it:
<thing>_idmirrors<thing>_label— the subject of the entry — whileselected_*_idmirrorsvalue, what the respondent chose. These areopt_/row_/col_identifiers of the same vocabularyGET /surveys/{survey_id}returns, so a webhook receiver and a REST client can join on them. Build durable mappings on the IDs and show the labels. But "same vocabulary" is not "always resolvable": an identifier describes the option as stored at submission time, andmatrixandrankingentries report the stored ID verbatim, so they can carry an identifier the current definition no longer contains. Expect a lookup against today'sGET /surveys/{survey_id}to miss, and handle the miss. selected_option_idis absent, not null, on entries that cannot have one. Text, number and date answers have no option identity, so the key does not appear at all. Fortext-ratingthe identifier is acol_value, because that type stores its choices as matrix columns.- A single-select
selected_option_id: nullis genuinely ambiguous. Unlike matrix and ranking, a single-select entry does not report an unresolvable ID: whenever the stored value is not a currently defined option,selected_option_idisnulland the raw stored string lands invalue. That covers two different events — the respondent typed a free-text "other" answer, or the option they picked was deleted after they submitted — and nothing in the payload separates them. On anallow_otherquestion theother_textcompanion carries that same raw string in both cases, so it does not disambiguate either. If you need to tell them apart, keep your own history of the survey definition. - Unanswered cells are always emitted. Every defined matrix row, rank position, and multiple-choice option produces an entry — unanswered ones carry
value: null(orvalue: falsefor multiple-choice options). Which cells exist follows the current definition, so an edit to the survey changes it; and the ranking and retained-deleted-row counts vary per response even without one, as How much ofanswersis fixed describes. - Deleted matrix rows are retained. If a stored response still holds an answer for a matrix row that is no longer in the current definition,
answerscarries an extra matrix entry for it after the current rows. Itsrow_idis the retained row key on its own, and itsrow_labelis the canonical deleted-row marker followed by that key —"Deleted row row_4b91c2e7". Read the ID; do not parse the label. - Survey order. Entries follow the survey's question order. Within a question, multiple-choice / matrix / ranking entries follow the order of the corresponding
options/matrix_rowsarrays; retained deleted matrix rows follow the current rows in stable key order; theother_textcompanion comes last. - Reading the
allow_othercompanion. Forsingle-choice/dropdownwithallow_other: true, the companion tells you whether free text was stored, not which option was chosen:- Companion
other_text: null→ nothing was stored in the free-text field. The main entry'svalueis a regular option's label. - Companion
other_text: "<string>"→ the main entry'svaluecarries the same string. Usually the respondent typed it; it can also be the raw ID of an option deleted since submission (see the ambiguity above). Legacy records that stored only the "Other" marker without free text surface the literal string"Other"in both fields.
- Companion
- Orphaned answers are dropped from
answers, kept inanswers_flat. If a stored answer refers to a question that no longer exists in the survey definition, no cell is emitted for it —answersfollows the definition.answers_flatfollows the stored record and still carries it under its stored key. Both are deliberate; a consumer iteratinganswers_flatmust tolerate keys it cannot resolve to a question. - No scale bounds. Rating/scale/nps/thumbs entries do not include
min/max; read these from the survey definition viaGET /surveys/{survey_id}if you need them.
Question Types
Concrete entry shapes for every question type — the groups and ordering mirror the REST API → Question Types section so you can jump back and forth between the create-payload shape and the webhook-receive shape for the same type.
Every example shows what one question of that type contributes to the response.answers array. Any single-choice, multiple-choice, or dropdown question with allow_other: true emits one additional other_text entry after its main entries — see the dedicated example at the bottom of this page.
The question_id values below are illustrative only. The field is an opaque string whose shape depends on how the question was created — q_text and q_<hex> both occur — so compare it for equality and never parse it. What is guaranteed is stability: an ID stays with its question for the survey's lifetime, which is what makes it a usable join key.
Entries that refer to an answer option carry its identifier too — option_id / row_id next to the matching label, and selected_option_id / selected_column_id next to value. These are the same opt_ / row_ / col_ identifiers GET /surveys/{survey_id} returns, and unlike labels they survive a rename.
| Question type | Entries per response | Value | Distinguishing field |
|---|---|---|---|
| text, text-long, email, phone, date | 1 | string or null | — |
| number, rating, scale, nps, thumbs | 1 | number or null | — |
| yes-no, privacy | 1 | boolean or null | — |
| single-choice, dropdown, image-single-choice, text-rating | 1 | option label or null | — |
| multiple-choice, image-multiple-choice | N (one per defined option) | boolean | option_label |
| matrix | R (one per defined row), +1 per retained deleted row | column label or null | row_label |
| ranking | N = the larger of the defined option count and this response's stored ranking | option label at this rank or null | rank_position |
…plus allow_other: true | +1 | — | other_text (replaces value) |
| content | — (not emitted) | — | — |
Selection
Single choice — single-choice
One entry whose value is the picked option's label. When the stored value is not a currently defined option — a free-text "other" answer, or an option deleted since submission — selected_option_id is null and value carries the raw stored string. The two cases are not distinguishable from the payload.
{
"question_id": "q_c4e18b732a564f0d91b86e3a7d5c2f14",
"question_title": "How did you hear about us?",
"question_type": "single-choice",
"selected_option_id": "opt_3f9a2c71",
"value": "Social media"
}
Image single choice — image-single-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | object[] | Yes | {label, image_url} entries, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "image-single-choice",
"question": "Which area did you like best?",
"options": [
{ "label": "Lava gorge", "image_url": "https://cdn.example.com/gorge.jpg" },
{ "label": "Arena", "image_url": "https://cdn.example.com/arena.jpg" }
]
}
Image multiple choice — image-multiple-choice
| Field | Type | Required | Notes |
|---|---|---|---|
question | string | Yes | Question text |
required | boolean | No | Whether an answer is required |
options | object[] | Yes | {label, image_url} entries, at least 2 and at most 100 |
randomize_options | boolean | No | Shuffle option order per respondent |
min_selections | integer | No | Fewest options a respondent must tick |
max_selections | integer | No | Most options a respondent may tick |
subtitle | string | No | Optional subtitle text |
show_subtitle | boolean | No | Whether to display the subtitle |
{
"type": "image-multiple-choice",
"question": "Which materials clean water?",
"min_selections": 1,
"options": [
{ "label": "Gravel", "image_url": "https://cdn.example.com/gravel.png" },
{ "label": "Cotton", "image_url": "https://cdn.example.com/cotton.webp" }
]
}
Multiple choice — multiple-choice
N entries — one per defined option — each carrying the option's label and a boolean value indicating whether the respondent selected it.
[
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"question_title": "Features?",
"question_type": "multiple-choice",
"option_id": "opt_9c14e7a3",
"option_label": "Pricing",
"value": true
},
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"question_title": "Features?",
"question_type": "multiple-choice",
"option_id": "opt_2d68b0f5",
"option_label": "Support",
"value": false
},
{
"question_id": "q_7b2f4e119c3a4a1d8e225f9d3b0a71e8",
"question_title": "Features?",
"question_type": "multiple-choice",
"option_id": "opt_7e35c9d1",
"option_label": "Speed",
"value": false
}
]
Dropdown — dropdown
One entry whose value is the picked option's label (or the free-text answer when the user picked "Other").
{
"question_id": "q_d94b1a6f2e774b8e87c13d4f9a2e05bd",
"question_title": "Select your country",
"question_type": "dropdown",
"selected_option_id": "opt_5a71f3c8",
"value": "Germany"
}
Yes / No — yes-no
One entry with a boolean value (or null if unanswered).
{
"question_id": "q_5e3f8c297a144d6b91e02a7c4b8f3d91",
"question_title": "Would you use this product again?",
"question_type": "yes-no",
"value": true
}
Rating
Star rating — rating
One entry with a numeric value from 1 to max (or null if unanswered).
{
"question_id": "q_ac8d0f125b434f2a8c7e1e9f6b3a7d42",
"question_title": "How would you rate our service?",
"question_type": "rating",
"value": 4
}
Thumbs — thumbs
One entry with a numeric value from 1 to max (or null if unanswered).
{
"question_id": "q_3b9e2c478f154a3d92c67d1f8e4b5a09",
"question_title": "Did you enjoy this experience?",
"question_type": "thumbs",
"value": 4
}
Number rating — scale
One entry with a numeric value from 1 to max (or null if unanswered).
{
"question_id": "q_6d4a8f132e514b7c83f95a2c9d6e1f78",
"question_title": "How satisfied are you?",
"question_type": "scale",
"value": 8
}
Text rating — text-rating
One entry whose value is the label of the picked column (or null if unanswered).
{
"question_id": "q_9c2e5a816b734d1f8a4e3b7f2d9c4e15",
"question_title": "How do you feel?",
"question_type": "text-rating",
"selected_option_id": "col_6b209d4f",
"value": "Good"
}
NPS — nps
One entry with a numeric value from 0 to 10 (or null if unanswered).
{
"question_id": "q_1f7b4e925c864a3d91f26e4a8c7d9b31",
"question_title": "How likely are you to recommend us?",
"question_type": "nps",
"value": 9
}
Text & Input
Short text — text
One entry with a string value (or null if unanswered).
{
"question_id": "q_4a8e2f163d954b7c82a19f5e6c3d1b74",
"question_title": "What is your name?",
"question_type": "text",
"value": "Jane Doe"
}
Long text — text-long
One entry with a string value (or null if unanswered).
{
"question_id": "q_8d3c1e749f424b5a87e62a1f7b4c9d53",
"question_title": "Please share additional feedback",
"question_type": "text-long",
"value": "Great product, very easy to use."
}
Email — email
One entry with a string value (or null if unanswered).
{
"question_id": "q_2e9f6a384c714d8b93a51b7e4f2c8a69",
"question_title": "Your email address",
"question_type": "email",
"value": "jane@example.com"
}
Phone — phone
One entry with a string value (or null if unanswered).
{
"question_id": "q_7c4b9e125a384f6d81e93a2f8b7c4d61",
"question_title": "Your contact number",
"question_type": "phone",
"value": "+49 30 1234567"
}
Number — number
One entry with a numeric value (or null if unanswered).
{
"question_id": "q_0b3e8a576d214f9c84b75e1a9c3d7f28",
"question_title": "How many employees does your company have?",
"question_type": "number",
"value": 42
}
Date — date
One entry with an ISO-8601 date string as value (or null if unanswered).
{
"question_id": "q_5a7f2c831e964b4d87c39d4e6a1f8b52",
"question_title": "When did you first use our product?",
"question_type": "date",
"value": "2024-03-15"
}
Advanced
Matrix — matrix
R entries — one per defined row. Each entry carries the row's label and the column label the respondent picked as value. Unanswered rows still produce an entry with value: null.
[
{
"question_id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"question_title": "Rate",
"question_type": "matrix",
"row_id": "row_1d47a8e2",
"row_label": "Quality",
"selected_column_id": "col_3e7a1b58",
"value": "Excellent"
},
{
"question_id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"question_title": "Rate",
"question_type": "matrix",
"row_id": "row_8f3b25c7",
"row_label": "Speed",
"selected_column_id": null,
"value": null
}
]
A stored answer for a row that has since been removed from the survey is not discarded. It follows the current rows as one extra entry whose row_id is the retained row key and whose row_label is the deleted-row marker plus that key:
{
"question_id": "q_3f8d4b217a954c6e89b12d5e7f1c9a34",
"question_title": "Rate",
"question_type": "matrix",
"row_id": "row_4b91c2e7",
"row_label": "Deleted row row_4b91c2e7",
"selected_column_id": "col_6b209d4f",
"value": "Good"
}
Ranking — ranking
N entries — one per rank position, with the option placed at that rank as value. N is the larger of the question's current option count and the length of this response's stored ranking, so a response submitted before an option was deleted carries one rank more than a later one. The extra position reports the stored ID it still holds, in both selected_option_id and value.
[
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"question_title": "Priorities",
"question_type": "ranking",
"rank_position": 1,
"selected_option_id": "opt_e21a9c48",
"value": "C"
},
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"question_title": "Priorities",
"question_type": "ranking",
"rank_position": 2,
"selected_option_id": "opt_4c8e1a92",
"value": "A"
},
{
"question_id": "q_6e2c9a745b384f1d92e78a4c7b3d1f05",
"question_title": "Priorities",
"question_type": "ranking",
"rank_position": 3,
"selected_option_id": "opt_b73f5d06",
"value": "B"
}
]
Content — content
Content blocks (headings, dividers, static text) are not emitted — they are display-only and never appear in response.answers.
Privacy Policy — privacy
One entry with a boolean value — true if the respondent consented, false if they did not.
{
"question_id": "q_9d1e4a683c724b5f86a95d7c2f8e1b43",
"question_title": "Privacy Policy",
"question_type": "privacy",
"value": true
}
allow_other: true companion
Any single-choice, multiple-choice, or dropdown question that was created with allow_other: true emits one additional entry after its main entries. This companion uses the key other_text in place of value — a consumer can identify it unambiguously by checking "other_text" in entry. The content is the respondent's free-text answer, or null if they did not use the "other" field.
{
"question_id": "q_c4e18b732a564f0d91b86e3a7d5c2f14",
"question_title": "How did you hear about us?",
"question_type": "single-choice",
"other_text": "From a conference"
}
This companion is where the free text lives. It is not a way to tell a typed answer apart from a deleted option: for a single-select, an unresolvable stored value lands in other_text and in the main entry's value alike, whichever of the two produced it.
Delivery Guarantees
- At-least-once delivery. On any failure (non-2xx response, connection error, timeout) a delivery is attempted 3 times in total — the first attempt plus 2 retries, after about 1 and 10 minutes with ±20% jitter. A receiver’s
Retry-Afterheader can extend the delay up to 24 hours. Retries and new deliveries share a limit of 5 starts/second and 2 concurrent calls per destination URL. Ensure your handler is idempotent — useX-EmpirioAi-Delivery-Idto deduplicate. - 10-second delivery timeout. Return a 2xx status within 10 seconds; longer work should be queued asynchronously on your side. (empirio allows itself 12 seconds for the internal hop that makes the call, so 10 is the budget your endpoint actually gets.)
MCP
Overview
The Model Context Protocol (MCP) server allows AI agents — ChatGPT, Claude, and any MCP-compatible host — to interact with your empirio.ai surveys and response data.
MCP is available on every plan, including Free — no paid plan is required. Access is gated only by OAuth authentication and per-tool scope, which is true of the REST API, the CLI and Webhooks as well.
Server URL
https://platform.empirio.ai/mcp
Authentication
OAuth 2.1 with PKCE. Clients auto-discover endpoints via .well-known metadata — no manual configuration needed.
| Property | Value |
|---|---|
| Protocol | MCP Streamable HTTP (stateless) |
| Auth | OAuth 2.1 + PKCE (auto-discovered) |
| Scopes | surveys, responses |
Scopes
| Scope | Access |
|---|---|
surveys | Create, read, edit, delete, publish, unpublish, revert, duplicate surveys |
responses | List, aggregate, export, delete responses; cross-tabulation; chart export |
All tools are listed regardless of the current token scope. If a tool requires a scope the token doesn't have, the server responds with HTTP 403 plus WWW-Authenticate: Bearer ... error="insufficient_scope" scope="<required-scope>" so the client can re-authorize with additional permissions. The advisory JSON-RPC body also includes error.data.code = "insufficient_scope" with the required and current scopes.
Write tools accept an optional idempotency_key in their MCP input payload. A call without one executes normally without replay caching. Supply a high-entropy value only when the client can retain it; after a timeout or unknown result, retry with exactly the same key and arguments. Use a new key for a different call.
Rate limits
| Metric | Limit |
|---|---|
| Read tools | 60 / minute per user |
| Write tools | 20 / minute per user |
survey_create and survey_duplicate | 10 / hour and 50 / day per account, shared between the two — charged only when the request reaches the work it pays for |
Quick start
ChatGPT (Responses API)
{
"model": "gpt-4o",
"tools": [{
"type": "mcp",
"server_label": "empirio",
"server_url": "https://platform.empirio.ai/mcp",
"require_approval": "never"
}],
"input": "List my surveys"
}
Claude (Settings → Connectors)
Add as a remote MCP server:
- URL:
https://platform.empirio.ai/mcp - Auth: OAuth 2.1 (auto-discovered via
.well-known)
Examples
Once connected, your AI agent can handle prompts like these. Each example shows the tool sequence the model will typically follow.
-
"Show me the responses from my latest customer-satisfaction survey." →
survey_list→responses_survey_stats→responses_aggregates -
"Create a 5-question NPS survey for our new product and publish it." →
survey_create(mode: "ai") →survey_publish -
"Cross-tabulate 'How satisfied are you?' against 'Would you recommend us?' for survey X." →
survey_get→ take IDs frompublished.questions→responses_crosstab -
"Export the responses from survey X as an editable PowerPoint deck." →
responses_export_charts(format: "pptx") -
"Delete the last 10 incomplete responses from survey X." →
responses_list(include_incomplete_participations: true) →responses_delete(mode: "rows")
Error codes
Tool responses use a consistent envelope. On failure the response is { "ok": false, "error": { "code": ..., "message": ... } }.
| Code | Meaning | Typical resolution |
|---|---|---|
insufficient_scope | Token is missing a scope the tool requires | Re-authorize the client requesting the scopes named in the challenge |
not_authorized | Access token is missing, invalid, or expired | Refresh or re-issue the token |
forbidden | Caller has no access to the target resource | Verify ownership or collaboration role |
plan_features_exceeded | Publishing is blocked because the survey uses features the OWNER's plan does not include | error.message names every blocking feature and its plan. Remove them or have the owner upgrade — a collaborator's own upgrade cannot lift this gate |
plan_feature_required | The requested export or other single operation is a feature the survey OWNER's plan does not include | Have the owner upgrade, or skip the operation; changing the survey does not unlock the operation itself |
not_found | Survey, response, or related resource does not exist | Check the survey_id / filters |
validation_error | Input does not match the tool's schema | Inspect error.message for the offending field and retry |
conflict | The request conflicts with current state, e.g. a survey_edit whose working draft was changed by someone else while the edit was being prepared | Re-read the draft, rebuild the edit on top of it, and send it again; retrying the original unchanged will keep failing |
survey_not_canonical | The stored survey holds a value the canonical contract no longer accepts, so the write cannot carry it forward. Nothing in the request caused it | Repair the stored value, then retry. error.message names the field. For an option label, a survey_edit option_operations rename fixes it without rewriting the list, keeping the identifier and the answers that reference it |
idempotency_in_progress | A call with the same idempotency_key is still running | Wait briefly, then retry with the same key |
idempotency_key_conflict | The idempotency_key is already bound to a different request body | Use a new key for a new request; reuse a key only to replay the identical request |
rate_limited | Per-user rate limit exceeded | Back off and retry after the indicated window |
edge_error | Upstream platform service returned a 5xx | Retry with the same key and arguments if the original call supplied a retained key; otherwise inspect state before retrying |
internal_error | Unexpected server-side failure | Retry with the same key and arguments if the original call supplied a retained key; otherwise inspect state first. Contact support if it persists |
Every surface answers in English and puts the whole message in error.message; there is no error.details and no localization. An agent relays or acts on message directly.
Support
- Email: info@empirio.ai
- Privacy policy: empirio.ai/privacy-policy
- Terms of service: empirio.ai/terms-of-service
Tools
Survey
| Tool | Description | Write | Destructive |
|---|---|---|---|
survey_list | List all surveys owned by or shared with the authenticated user | ||
survey_get | Get full survey details including questions, settings, and design | ||
survey_create | Create a new survey draft | ✓ | |
survey_edit | Edit the working draft | ✓ | ✓ |
survey_publish | Publish an existing draft or update a live survey from the working draft | ✓ | ✓ |
survey_revert_draft | Replace the working draft with the currently published version, discarding every unpublished edit | ✓ | ✓ |
survey_duplicate | Clone a survey as a new unpublished draft | ✓ | |
survey_delete | Remove a survey and its responses from the account (irreversible; rows are purged after an internal 14-day window) | ✓ | ✓ |
survey_unpublish | Unpublish a live survey and move it back to draft status | ✓ | ✓ |
All survey tools require the surveys scope.
Response
| Tool | Description | Write | Destructive |
|---|---|---|---|
responses_list | List paginated responses with optional date and answer filters | ||
responses_aggregates | Per-question aggregate statistics (counts per answer) | ||
responses_crosstab | Cross-tabulate answers between two non-matrix survey questions | ||
responses_survey_stats | Survey statistics — views, starts, completions, incompletes, avg. duration | ||
responses_delete | Delete responses by ranked row numbers or delete all | ✓ | ✓ |
responses_export | Export responses as CSV, XLSX, JSON, or SPSS | ||
responses_export_charts | Export charts as PowerPoint (editable or image-based), PDF, Word, ZIP of PNGs, or chart JSON |
All response tools require the responses scope.
Both columns are the tool's own MCP annotations, generated from the same catalog entry the tool is registered from: Write is readOnlyHint: false — the tool changes something you own — and Destructive is destructiveHint: true — it removes something, is irreversible, or changes what other people see.
They are annotations, not the platform's accounting, and the two can differ: a tool that stores an artifact of its own is rate-limited as a write even when nothing of yours changes, so it can be annotated read-only and still offer an optional idempotency_key. Take that option from the tool's input schema rather than from this column.
CLI
Overview
The empirio CLI (@empirio-ai/cli) is a command-line surface over the REST API. It ships as an npm package and delegates every call to the REST endpoints documented in this reference.
Its commands are generated from this document, one per operation — with the deliberate exception of the operations marked x-cli-hidden. The webhook endpoints carry that marker: they exist for automation platforms, which call them over HTTP, and the CLI has no webhook command group at all. Manage endpoints in Settings → Integrations or over REST.
Package
npm install -g @empirio-ai/cli
# or
npx @empirio-ai/cli --help
Binary name: empirio. Requires Node.js ≥ 20.
See Commands (next in the sidebar) for the full reference of every
empirio …subcommand, its parameters, and usage examples.
Authentication
Two modes. Precedence per invocation: EMPIRIO_API_KEY env var → OS keychain → ~/.config/empirio/credentials.json.
| Mode | Command | When |
|---|---|---|
| OAuth 2.1 (PKCE) | empirio login | Interactive developer workstations |
| API key | EMPIRIO_API_KEY=sk_… empirio … | CI / headless / automation |
The OAuth flow opens the default browser, the user approves consent on www.empirio.ai, and the CLI receives the authorization code on an ephemeral http://127.0.0.1:<port>/callback listener (RFC 8252 §7.3). Tokens are stored in the OS keychain via keytar and fall back to a chmod 0600 file on systems where keytar is unavailable. Refresh tokens are rotated on every use; the access-token TTL is one hour.
Global flags
| Flag | Effect |
|---|---|
--json | Emit machine-readable JSON instead of pretty tables |
--quiet | Suppress non-error output |
Both are declared on the root command, so they work on either side of the subcommand: empirio --json survey list and empirio survey list --json are equivalent.
--idempotency-key <uuid> is not global. It is declared on each write subcommand separately, so it has to come after the command:
empirio survey delete --survey-id <uuid> --idempotency-key <uuid> # works
empirio --idempotency-key <uuid> survey delete --survey-id <uuid> # error: unknown option '--idempotency-key'
Every write command accepts it and no read command does; the per-command tables under Commands list it wherever it applies.
Object-valued body fields (metadata, design, settings, welcome_page, logic_rule_operations, translations, …) accept a JSON string on the command line: empirio survey edit --metadata '{"title":"Renamed"}' ….
Boolean flags take an explicit value: --is-active true. Accepted spellings are true, 1, yes, on and false, 0, no, off, matched case-insensitively; anything else is an error rather than a silent false.
Environment overrides
For local development, set both origins because the platform API and browser consent page run on separate servers:
EMPIRIO_PLATFORM_URL=http://localhost:7788 \
EMPIRIO_APP_URL=http://localhost:8081 \
empirio login
Any non-loopback host must be https://; the CLI refuses to send credentials over plain HTTP otherwise. For hosted environments, EMPIRIO_APP_URL is auto-derived when EMPIRIO_PLATFORM_URL uses a platform.* host. Other host shapes require both variables explicitly so an OAuth code is never requested from a different environment.
Support
- Email: info@empirio.ai
- Privacy policy: empirio.ai/privacy-policy
- Terms of service: empirio.ai/terms-of-service
Commands
This section documents every empirio … subcommand that calls the API, with its parameters and a usage example. empirio commands is the one omission: it lists the available commands locally and calls nothing, so it has no parameters to document — run it, or empirio --help, to see the same list. Operations marked x-cli-hidden in this document generate no subcommand and appear nowhere below; the webhook endpoints are the only ones. Flags use kebab-case (e.g. --survey-id, --ai-prompt); object-valued flags accept a JSON string.
All examples assume you are authenticated (either via empirio login or EMPIRIO_API_KEY).
Authentication
empirio login
Start the browser-based OAuth 2.1 (PKCE) flow. On success, tokens are written to the OS keychain (or ~/.config/empirio/credentials.json fallback).
| Parameter | Type | Required | Description |
|---|---|---|---|
--scope | string | No | Requested OAuth scopes, space-separated. Default: surveys responses. |
empirio login
# or request a narrower scope:
empirio login --scope "surveys"
empirio logout
Revoke the stored access + refresh tokens server-side and clear the local credential store.
empirio logout
empirio whoami
Return the authenticated user, plan, and active scopes. Exempt from the scope gate so every authenticated caller can inspect their own state.
empirio whoami
empirio whoami --json
Survey
empirio survey list
List surveys owned by or shared with the authenticated user.
| Parameter | Type | Required | Description |
|---|---|---|---|
--status | draft | published | all | No | Filter by publication status. Default: all. |
--limit | integer (1–100) | No | Page size. Default: 50. |
--offset | integer (≥ 0) | No | Zero-based pagination offset. |
empirio survey list
empirio survey list --status published --limit 10
empirio survey list --json | jq '.surveys[].id'
empirio survey get
Fetch both complete survey definitions. draft is the current working state used by edits and
the next publish. published is the latest published state, including stable question_id and
option_id values, or null before the first publish. Root fields describe current access,
the run window, and effective draft, scheduled, live, or ended status.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to retrieve. |
empirio survey get --survey-id 9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b
empirio survey create
Create a new (unpublished) survey draft in either manual mode (you supply title + questions) or AI mode (you supply a prompt and the server generates the survey).
| Parameter | Type | Required | Description |
|---|---|---|---|
--mode | manual | ai | Yes | Creation mode. |
--ai-prompt | string | In AI | Prompt that describes the survey to generate (AI mode only). |
--questions | JSON array | No | Pre-built question objects (manual mode). Give a question a ref to reference it from --logic-rules and --translations in the same call. See Question Types in the REST sidebar for the per-type schema. |
--metadata | JSON object | In manual | Survey-level metadata (title, display_mode). title is required in manual mode; in AI mode every field here overrides the generated value. |
--welcome-page | JSON object | No | Welcome page configuration. |
--end-page | JSON object | No | End page configuration. |
--design | JSON object | No | Design customization (template, primary color, logo). |
--settings | JSON object | No | Survey behavior settings (including master_locale and auto_advance). |
--schedule | JSON object | No | The survey's run window — {"start_at": …, "end_at": …}, ISO 8601 instants with an offset. The survey activates and deactivates itself at those times without a further publish, so this takes effect immediately rather than waiting for survey publish. Send null for either to remove it. |
--logic-rules | JSON array | No | Logic rules the new survey should have, as a plain list — question_id and target_question_id name a question's ref from this call. Max 200. |
--translations | JSON object | No | Per-locale translations. Question translations are keyed by a question's ref, not by question_id. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
Manual mode:
empirio survey create \
--mode manual \
--metadata '{"title":"Customer Satisfaction Q2"}' \
--questions '[
{"type":"nps","question":"How likely are you to recommend us?","required":true},
{"type":"text-long","question":"What could we improve?"}
]' \
--settings '{"master_locale":"en-US","auto_advance":true}'
AI mode:
empirio survey create \
--mode ai \
--ai-prompt "Five-question NPS survey for a mobile banking app, in German, with a content block explaining data-protection"
AI mode with design override:
empirio survey create \
--mode ai \
--ai-prompt "Short post-purchase CSAT survey" \
--design '{"template":"modern","primary_color":"#ff5a5f"}' \
--settings '{"master_locale":"de-DE","auto_advance":false}'
Rate limit:
survey_createis additionally capped at 10 / hour and 50 / day per account. On overflow the call returns429withRetry-After.
empirio survey edit
Apply changes to the survey's working draft (does not publish). Manual mode accepts targeted section patches; AI mode regenerates or adjusts via prompt.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to edit. |
--mode | manual | ai | Yes | Edit mode. |
--ai-prompt | string | In AI | Prompt describing the change (AI mode only). |
--question-operations | JSON array | No | Per-question ops: add / update / delete / reorder. An add may carry a ref so later operations in the same call can point at it. |
--option-operations | JSON array | No | Per-option ops inside a question. |
--metadata | JSON object | No | Patch survey title / display_mode. |
--welcome-page | JSON object | No | Replace welcome-page config. |
--end-page | JSON object | No | Replace end-page config. |
--logic-rule-operations | JSON array | No | Logic-rule operations (add, update, delete). Max 200 operations per request; a survey holds at most 200 logic rules in total. |
--design | JSON object | No | Design override. |
--settings | JSON object | No | Survey behavior settings, including auto_advance for respondent auto-navigation. |
--translations | JSON object | No | Per-locale translation patches. |
--schedule | JSON object | No | The survey's run window — {"start_at": …, "end_at": …}, ISO 8601 instants with an offset. The survey activates and deactivates itself at those times without a further publish, so this takes effect immediately rather than waiting for survey publish. Send null for either to remove it. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
Rename a question via manual mode:
empirio survey edit \
--survey-id 9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b \
--mode manual \
--question-operations '[
{"op":"update","question_id":"q_a3f8d1b2","changes":{"question":"Updated wording","required":true}}
]'
Add a new question at the end:
empirio survey edit --survey-id <uuid> --mode manual \
--question-operations '[
{"op":"add","position":{"type":"end"},"question":{"type":"text","question":"Any other feedback?"}}
]'
Add a question and point a logic rule at it, in one call:
# The `ref` names the new question before it has an identifier; the rule
# resolves it. The response reports the id it was assigned.
empirio survey edit --survey-id <uuid> --mode manual \
--question-operations '[
{"op":"add","ref":"followup","position":{"type":"end"},
"question":{"type":"text-long","question":"What could we improve?"}}
]' \
--logic-rule-operations '[
{"op":"add","rule":{"question_id":"q_a3f8d1b2","condition":"less-than",
"values":["7"],"action":"show-question","target_question_id":"followup"}}
]'
AI mode:
empirio survey edit --survey-id <uuid> --mode ai \
--ai-prompt "Add a demographics section with age range and country"
empirio survey publish
Publish a draft, or update a published survey from its working draft. Owner or editor.
The result reports the effective status; a future start returns scheduled plus a
public_url_note instead of implying the public link already accepts responses.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to publish. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio survey publish --survey-id 9f3a8b12-4c5d-4e6f-8a1b-0c2d3e4f5a6b
empirio survey unpublish
Move a published survey back to draft state. Owner or editor.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to unpublish. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio survey unpublish --survey-id <uuid>
empirio survey revert-draft
Replace the working draft with the currently published version. Owner or editor. Destructive.
Every unpublished edit in the draft is discarded and cannot be recovered through the API — no command hands it back. The live survey, its public link and its responses are unaffected. The result reports what was thrown away: the question count before and after, which sections differed, and the questions the draft had added, removed or changed.
A survey that has never been published answers 400 — there is nothing to fall back on. A draft that already matches the published version answers 200 with discarded: false.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey whose draft is replaced. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio survey revert-draft --survey-id <uuid>
empirio survey duplicate
Clone a survey as a new unpublished draft. Owner-only — the copy belongs to the caller, so it leaves the source survey's collaboration entirely.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Source survey. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio survey duplicate --survey-id <uuid>
empirio survey delete
Remove a survey from the account. Owner-only. Destructive.
The survey disappears from the API immediately: empirio survey list no longer returns it, and survey get, responses list and every other call on that survey_id answer 404 not_found — including a second survey delete. Any public link stops working, because the survey is unpublished as part of the same operation.
The underlying rows are not erased at that moment. The survey is marked for deletion 14 days out (scheduled_for_deletion_at = now() + 14 days) and its response rows are left in place; a nightly job removes the survey and cascades to its responses once that date passes. The window is an internal recovery net, not a user-facing undo — nothing in the API, the CLI or the app can bring a deleted survey back, so treat the command as final. Export anything you still need before calling it: the export endpoints answer 404 from the moment it returns.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to delete. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio survey delete --survey-id <uuid>
Responses
empirio responses list
List individual responses for a survey with per-question answers and rank-based row_no.
Each row's orphaned_answer_question_ids marks historical answer keys whose question
has since been deleted.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey whose responses to list. |
--date-from | ISO 8601 | No | Filter start. |
--date-to | ISO 8601 | No | Filter end. |
--since | ISO 8601 date-time | No | Exclusive lower bound on the participation timestamp (the created_at field of the returned rows). Pass the newest created_at you have already seen to receive only what arrived after it. Combines with --date-from/--date-to: the later of --since and --date-from wins, --date-to still caps the upper end. |
--order | desc | asc | No | Emission order of the page. Default desc (newest first). row_no is always the newest-first rank regardless of this setting, so ranks stay valid for responses delete under either order. asc cannot be combined with --include-incomplete-participations. |
--include-incomplete-participations | boolean | No | Include incomplete participations with saved progress. Default false. |
--limit | integer (1–1000) | No | Default 100. |
--offset | integer (0–100000) | No | Zero-based offset. |
empirio responses list --survey-id <uuid>
empirio responses list --survey-id <uuid> --date-from 2026-04-01 --limit 500
# Polling: ask only for what arrived after the newest response you already have.
empirio responses list --survey-id <uuid> --since 2026-04-05T14:22:31.123456Z
empirio responses aggregates
Get aggregate counts and percentages per current question. The result's
aggregates.orphanedQuestionIds separately names deleted questions still found in
historical answers.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey to aggregate. |
--question-ids | comma-separated list | No | Limit to specific questions (max 200). Omit for all. |
empirio responses aggregates --survey-id <uuid>
empirio responses aggregates --survey-id <uuid> --question-ids q_a3f8d1b2,q_7b2f4e11
empirio responses crosstab
Cross-tabulate two non-matrix questions (rows × columns). Matrix questions are rejected because the operation has no matrix-row selector.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey. |
--question-x | question_id | Yes | Row question. |
--question-y | question_id | Yes | Column question. |
empirio responses crosstab \
--survey-id <uuid> \
--question-x q_a3f8d1b2 \
--question-y q_7b2f4e11
empirio responses survey-stats
Summary statistics, camelCase because the payload mirrors the analytics core: surveyId, viewCount, startedResponses, completedResponses, completionRate (a percentage) and duration (average, shortest, longest, median, in seconds across completed responses). There is no last-response timestamp here — take it from the newest row of empirio responses list.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey. |
empirio responses survey-stats --survey-id <uuid>
empirio responses delete
Delete responses by response_id (--mode ids, preferred), by rank (--mode rows with --row-numbers from responses list), or wipe all responses (--mode all). Destructive.
Prefer --mode ids. A rank is resolved against the live response set at the moment of the call, so a response arriving between your responses list and your delete shifts every rank by one — and --row-numbers 1 then destroys the arrival you never saw. A response_id names one participation for the whole of its life.
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey. |
--mode | ids | rows | all | Yes | Targeting mode. |
--response-ids | comma-separated resp_… / part_… | In ids mode | response_id values from responses list (max 1000). Cannot be combined with --row-numbers. |
--row-numbers | comma-separated integers | In rows mode | Ranks from responses list (1-based, max 1000). |
--date-from / --date-to | ISO 8601 | No | Must mirror the responses list filter used for rank resolution. Ignored in ids mode. |
--include-incomplete-participations | boolean | No | Must mirror the responses list filter. Ignored in ids mode. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
In ids mode the answer carries unresolved_response_ids (ids that named no participation — the rest are still deleted) and already_deleted_response_ids (ids whose participation was already gone, answered as success with deleted_count: 0, which is what makes a retry safe). A request in which no id at all resolves is refused, naming them.
# Delete the two responses you just listed, by their stable ids:
empirio responses delete --survey-id <uuid> --mode ids --response-ids resp_LtJDYFHhAx6phdcj0zbHAQ,part_jSh1yYC83FWbZ5by4cz4bg
# Delete responses at row 3, 7, 12 (from the last `responses list` you ran):
empirio responses delete --survey-id <uuid> --mode rows --row-numbers 3,7,12
# Wipe everything:
empirio responses delete --survey-id <uuid> --mode all
empirio responses export
Export responses as CSV, XLSX, JSON, or SPSS. Returns a temporary signed download URL and
the actual file extension. SPSS is delivered as a ZIP bundle, so its response reports
format: "spss" together with file_extension: "zip".
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey. |
--format | csv | xlsx | json | spss | No | Default csv. |
--date-from / --date-to | ISO 8601 | No | Filter window. |
--include-incomplete-participations | boolean | No | Default false. |
--time-zone | IANA TZ | No | e.g. Europe/Berlin for exported timestamps. |
--answer-filters | JSON array | No | Filter by specific answer values (max 100). |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio responses export --survey-id <uuid> --format xlsx
empirio responses export --survey-id <uuid> --format csv --time-zone Europe/Berlin
empirio responses export-charts
Export aggregate charts in one of six formats: native editable PowerPoint (pptx), image-based PowerPoint (pptx_images), landscape A4 PDF (pdf), Word document (docx), ZIP of PNG charts (zip_images), or structured chart JSON (chart_json).
| Parameter | Type | Required | Description |
|---|---|---|---|
--survey-id | UUID | Yes | Survey. |
--format | pptx | pptx_images | pdf | docx | zip_images | chart_json | No | Default pptx. Binary formats return a 1h signed download URL; chart_json returns inline content. |
--date-from / --date-to | ISO 8601 | No | Filter window. |
--include-incomplete-participations | boolean | No | Default false. |
--time-zone | IANA TZ | No | e.g. Europe/Berlin. |
--answer-filters | JSON array | No | Same as responses export. |
--idempotency-key | UUID | No | Override the auto-generated idempotency key. |
empirio responses export-charts --survey-id <uuid> --format pptx
empirio responses export-charts --survey-id <uuid> --format pdf
empirio responses export-charts --survey-id <uuid> --format docx
empirio responses export-charts --survey-id <uuid> --format zip_images
empirio responses export-charts --survey-id <uuid> --format chart_json --json > charts.json