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/cli npm 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":

ScopeAccess
surveysCreate, read, edit, delete, publish, unpublish, revert, duplicate surveys
responsesList, aggregate, export, delete responses; cross-tabulation; chart export
webhooksRegister, 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.

MetricLimit
Read operations60 / minute per account
Write operations20 / minute per account
Failed authentications120 / minute per credential (per IP when no credential is presented)
POST /surveys and POST /surveys/{survey_id}/duplicate10 / 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the window (60 read / 20 write)
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix 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."
  }
}
CodeStatusDescription
validation_error400Request parameters failed validation
idempotency_required400Idempotency key required but not provided
not_authorized401Missing or invalid API key
insufficient_scope403Key does not have the required scope
plan_features_exceeded403The survey uses a feature the survey owner's plan does not include
plan_feature_required403The requested operation is a feature the survey owner's plan does not include
forbidden403Action not permitted
not_found404Resource or route not found
method_not_allowed405HTTP method not allowed for this endpoint
conflict409The request conflicts with current state — a concurrently modified working draft, say — and stays so until the request changes
survey_not_canonical409The 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_progress409Duplicate request is still being processed
idempotency_key_conflict409The Idempotency-Key was already used for a different request body
rate_limited429Rate limit exceeded
edge_error500An 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_data400The survey has no answers to chart yet. Every chart format answers this way; collect responses and request the export again
export_error500An export could not be produced
export_timeout500A 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_error500The export artifact could not be stored
export_expired404The 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_error500A database operation failed
response_counts_unavailable500GET /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_error500The operation ran but its idempotency record could not be committed. Retry with the same Idempotency-Key
internal_error500Unexpected 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

  1. POST /surveys — create a draft
  2. PATCH /surveys/{survey_id} — optional iterative edits
  3. POST /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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsstring[]YesAnswer labels, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
allow_otherbooleanNoAllow a free-text "other" answer
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsobject[]Yes{label, image_url} entries, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsobject[]Yes{label, image_url} entries, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
min_selectionsintegerNoFewest options a respondent must tick
max_selectionsintegerNoMost options a respondent may tick
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsstring[]YesAnswer labels, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
allow_otherbooleanNoAllow a free-text "other" answer
min_selectionsintegerNoMinimum options the respondent must select (≥ 1, ≤ options.length). See the note below — it does not set required.
max_selectionsintegerNoMaximum options the respondent may select (≥ 1, ≥ min_selections, ≤ options.length).
subtitlestringServer-assigned when omittedDefaults to a localized "Multiple selection possible" with show_subtitle: true. A subtitle you send is kept, and show_subtitle: false is honoured.
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsstring[]YesAnswer labels, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
allow_otherbooleanNoAllow a free-text "other" answer
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "dropdown",
  "question": "Select your country",
  "options": ["Germany", "Austria", "Switzerland"]
}
Yes / No — yes-no
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "yes-no",
  "question": "Would you use this product again?",
  "required": true
}
Rating
Star rating — rating
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
min—NeverNot accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error.
maxnumberNo2–10 (default 5)
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "rating",
  "question": "How would you rate our service?",
  "required": true,
  "max": 5
}
Thumbs — thumbs
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
min—NeverNot accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error.
maxnumberNo2–10 (default 5)
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "thumbs",
  "question": "Did you enjoy this experience?",
  "required": true,
  "max": 5
}
Number rating — scale
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
min—NeverNot accepted. The lower bound is always 1 and is set by the server; sending min returns validation_error.
maxnumberNo2–20 (default 10)
scale_labels{ min?: string, max?: string }Server-assigned when omittedLabel texts for scale ends. Defaults to {"min": "Low", "max": "High"}, which survey_get then reports.
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
matrix_columnsstring[]YesLabel 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.
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "text-rating",
  "question": "How do you feel?",
  "matrix_columns": ["Bad", "Neutral", "Good", "Great"]
}
NPS — nps
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "nps",
  "question": "How likely are you to recommend us?",
  "required": true
}
Text & Input
Short text — text
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "text",
  "question": "What is your name?",
  "required": true
}
Long text — text-long
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "text-long",
  "question": "Please share additional feedback"
}
Email — email
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "email",
  "question": "Your email address",
  "required": true
}
Phone — phone
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "phone",
  "question": "Your contact number"
}
Number — number
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "number",
  "question": "How many employees does your company have?",
  "required": true
}
Date — date
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether to display the subtitle
{
  "type": "date",
  "question": "When did you first use our product?"
}
Advanced
Matrix — matrix
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
matrix_rowsstring[]YesRow labels, at least 1
matrix_columnsstring[]YesColumn labels, at least 1
randomize_rowsbooleanNoShuffle row order per respondent
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsstring[]YesItems to rank, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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.

FieldTypeRequiredNotes
questionstringYesTitle / label
contentstringYesBody text
image_urlstringNoHeader 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_pathsstring[]NoStorage 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—NeverNot 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
FieldTypeRequiredNotes
questionstringYesPolicy title
requiredbooleanNoIgnored. Always stored as true — consent cannot be optional. A false you send is accepted and overridden, and survey_get reports true
contentstringYesPolicy text shown to the respondent
privacy_checkbox_labelstringNoLabel displayed next to the consent checkbox
subtitle, show_subtitle—NeverNot 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
NameInTypeRequiredDescription
statusquery"all" | "draft" | "published"NoFilter by publication status
limitqueryintegerNoMax results to return (default 50)
offsetqueryintegerNoNumber of results to skip (default 0)
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

    • total integerrequired

      Total number of accessible surveys, ignoring pagination.

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
NameInTypeRequiredDescription
Idempotency-KeyheaderstringYesRequired 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.

    • 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`).

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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`.

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
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

    • warnings array of string

      Non-fatal problems, such as a translation for a locale the survey does not enable.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesThe survey ID
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

        • 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.

        • 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.

        • 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.

      • 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.

      • 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.

      • 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.

    • 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

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesThe survey ID to edit
Idempotency-KeyheaderstringYesRequired 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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`.

  • 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
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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
        • 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
        • reordered booleanrequired
      • 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.

        • 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.

        • 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
        • 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

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID to delete.
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

FeatureNameMinimum plan
premium_templatePremium templateLite
logic_rulesLogic jumpsLite
custom_colorsCustom colorsPlus
custom_logoCustom logoPlus
access_restrictionAccess restrictionPlus
hide_brandingHide brandingPro
multi_languageMulti-language surveysPro
Parameters
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesExisting survey ID to publish
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID to unpublish.
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey whose working draft is replaced by the currently published version.
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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
    • warnings array of string

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID to duplicate
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
date_fromquerystringNoISO 8601 date filter start
date_toquerystringNoISO 8601 date filter end
sincequerystringNoISO 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.
orderquery"asc" | "desc"NoEmission 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_participationsquerybooleanNoInclude incomplete participations with meaningful saved progress
limitqueryintegerNoDefault 100
offsetqueryintegerNoZero-based pagination offset.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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
    • total_count integerrequired
    • has_more booleanrequired

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Idempotency-KeyheaderstringYesRequired 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
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
question_idsqueryarray of stringNoSpecific question IDs (omit for all, max 200)
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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

        • 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.

        • 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.

      • 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.

    • 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.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
question_xquerystringYesFirst non-matrix question ID (rows)
question_yquerystringYesSecond non-matrix question ID (columns)
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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
      • 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
      • 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).

      • truncated booleanrequired

        True when the row/column set was truncated to stay under the max-categories limit.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Idempotency-KeyheaderstringYesRequired 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)

Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Idempotency-KeyheaderstringYesRequired 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)

  • time_zone string

    Reserved parity field for timezone-aware chart export pipelines.

Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

PropertyValue
DirectionOutbound (empirio → your server)
Triggersurvey.response.submitted
TransportHTTPS only (private IP ranges rejected)
SigningHMAC-SHA256 over <timestamp>.<raw request body>
Content typeapplication/json
Max endpoints per survey10
Retries3 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.

MethodPathPurpose
GET/surveys/{survey_id}/webhooksList endpoints on a survey
POST/surveys/{survey_id}/webhooksRegister an endpoint
PATCH/webhooks/{webhook_id}Change URL, label, or delivery state
DELETE/webhooks/{webhook_id}Remove an endpoint
POST/webhooks/{webhook_id}/rotate-secretIssue a new signing secret
POST/webhooks/{webhook_id}/deliveries/{delivery_id}/resolveResolve 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: false and the signing secret (64 hex characters), returned this one time only.
  • URL already registered on the survey → 200 with already_exists: true, the existing configuration, and secret: null. Lost secrets are replaced via POST /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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

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
NameInTypeRequiredDescription
survey_idpathstring (uuid)YesSurvey ID.
Idempotency-KeyheaderstringYesRequired 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
StatusDescription
200Successful response
201Resource created
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

    • 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.

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
NameInTypeRequiredDescription
webhook_idpathstring (uuid)YesWebhook ID.
Idempotency-KeyheaderstringYesRequired 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
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

    • 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.

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
NameInTypeRequiredDescription
webhook_idpathstring (uuid)YesWebhook ID.
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

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
NameInTypeRequiredDescription
webhook_idpathstring (uuid)YesWebhook ID.
Idempotency-KeyheaderstringYesRequired idempotency key for write operations. Reuse the same value when retrying the same request.
Responses
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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.

    • 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.

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
NameInTypeRequiredDescription
webhook_idpathstring (uuid)YesMake-managed webhook ID.
delivery_idpathstring (uuid)YesWebhook delivery job ID.
Idempotency-KeyheaderstringYesRequired 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
StatusDescription
200Successful response
400Validation error. `error.code` is one of: `validation_error`, `idempotency_required`, `no_chartable_data`.
401Unauthorized. `error.code` is one of: `not_authorized`.
403Forbidden. `error.code` is one of: `insufficient_scope`, `plan_features_exceeded`, `plan_feature_required`, `forbidden`.
404Not found. `error.code` is one of: `not_found`, `export_expired`.
409Conflict. `error.code` is one of: `conflict`, `survey_not_canonical`, `idempotency_in_progress`, `idempotency_key_conflict`.
429Rate limited. `error.code` is one of: `rate_limited`.
500Internal 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 forExtra fieldsvalueIdentify by
text, text-long, email, phone, date, number, rating, scale, nps, thumbs, yes-no, privacy, single-choice, dropdown, image-single-choice, text-ratingselected_option_id (single-select option types only)string / number / boolean / nullnone 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_labelboolean (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_idstring / 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_idstring / 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 typeEntriesFixed per survey?
text, number, date, single-select, …1yes
multiple-choiceone per currently defined optionyes
matrixone per currently defined rowyes
matrix, retained deleted rowone per row key this response still answers that the question no longer definesno — per response
rankingmax(currently defined options, this response's stored ranking length)no — per response
other_text companion1 per allow_other questionyes

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_id is 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, so question_id is 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>_id mirrors <thing>_label — the subject of the entry — while selected_*_id mirrors value, what the respondent chose. These are opt_ / row_ / col_ identifiers of the same vocabulary GET /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, and matrix and ranking entries report the stored ID verbatim, so they can carry an identifier the current definition no longer contains. Expect a lookup against today's GET /surveys/{survey_id} to miss, and handle the miss.
  • selected_option_id is 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. For text-rating the identifier is a col_ value, because that type stores its choices as matrix columns.
  • A single-select selected_option_id: null is 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_id is null and the raw stored string lands in value. 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 an allow_other question the other_text companion 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 (or value: false for 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 of answers is 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, answers carries an extra matrix entry for it after the current rows. Its row_id is the retained row key on its own, and its row_label is 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_rows arrays; retained deleted matrix rows follow the current rows in stable key order; the other_text companion comes last.
  • Reading the allow_other companion. For single-choice / dropdown with allow_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's value is a regular option's label.
    • Companion other_text: "<string>" → the main entry's value carries 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.
  • Orphaned answers are dropped from answers, kept in answers_flat. If a stored answer refers to a question that no longer exists in the survey definition, no cell is emitted for it — answers follows the definition. answers_flat follows the stored record and still carries it under its stored key. Both are deliberate; a consumer iterating answers_flat must 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 via GET /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 typeEntries per responseValueDistinguishing field
text, text-long, email, phone, date1string or null—
number, rating, scale, nps, thumbs1number or null—
yes-no, privacy1boolean or null—
single-choice, dropdown, image-single-choice, text-rating1option label or null—
multiple-choice, image-multiple-choiceN (one per defined option)booleanoption_label
matrixR (one per defined row), +1 per retained deleted rowcolumn label or nullrow_label
rankingN = the larger of the defined option count and this response's stored rankingoption label at this rank or nullrank_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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsobject[]Yes{label, image_url} entries, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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
FieldTypeRequiredNotes
questionstringYesQuestion text
requiredbooleanNoWhether an answer is required
optionsobject[]Yes{label, image_url} entries, at least 2 and at most 100
randomize_optionsbooleanNoShuffle option order per respondent
min_selectionsintegerNoFewest options a respondent must tick
max_selectionsintegerNoMost options a respondent may tick
subtitlestringNoOptional subtitle text
show_subtitlebooleanNoWhether 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-After header 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 — use X-EmpirioAi-Delivery-Id to 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.

PropertyValue
ProtocolMCP Streamable HTTP (stateless)
AuthOAuth 2.1 + PKCE (auto-discovered)
Scopessurveys, responses

Scopes

ScopeAccess
surveysCreate, read, edit, delete, publish, unpublish, revert, duplicate surveys
responsesList, 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

MetricLimit
Read tools60 / minute per user
Write tools20 / minute per user
survey_create and survey_duplicate10 / 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.

  1. "Show me the responses from my latest customer-satisfaction survey." → survey_list → responses_survey_stats → responses_aggregates

  2. "Create a 5-question NPS survey for our new product and publish it." → survey_create (mode: "ai") → survey_publish

  3. "Cross-tabulate 'How satisfied are you?' against 'Would you recommend us?' for survey X." → survey_get → take IDs from published.questions → responses_crosstab

  4. "Export the responses from survey X as an editable PowerPoint deck." → responses_export_charts (format: "pptx")

  5. "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": ... } }.

CodeMeaningTypical resolution
insufficient_scopeToken is missing a scope the tool requiresRe-authorize the client requesting the scopes named in the challenge
not_authorizedAccess token is missing, invalid, or expiredRefresh or re-issue the token
forbiddenCaller has no access to the target resourceVerify ownership or collaboration role
plan_features_exceededPublishing is blocked because the survey uses features the OWNER's plan does not includeerror.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_requiredThe requested export or other single operation is a feature the survey OWNER's plan does not includeHave the owner upgrade, or skip the operation; changing the survey does not unlock the operation itself
not_foundSurvey, response, or related resource does not existCheck the survey_id / filters
validation_errorInput does not match the tool's schemaInspect error.message for the offending field and retry
conflictThe request conflicts with current state, e.g. a survey_edit whose working draft was changed by someone else while the edit was being preparedRe-read the draft, rebuild the edit on top of it, and send it again; retrying the original unchanged will keep failing
survey_not_canonicalThe stored survey holds a value the canonical contract no longer accepts, so the write cannot carry it forward. Nothing in the request caused itRepair 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_progressA call with the same idempotency_key is still runningWait briefly, then retry with the same key
idempotency_key_conflictThe idempotency_key is already bound to a different request bodyUse a new key for a new request; reuse a key only to replay the identical request
rate_limitedPer-user rate limit exceededBack off and retry after the indicated window
edge_errorUpstream platform service returned a 5xxRetry with the same key and arguments if the original call supplied a retained key; otherwise inspect state before retrying
internal_errorUnexpected server-side failureRetry 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
ToolDescriptionWriteDestructive
survey_listList all surveys owned by or shared with the authenticated user
survey_getGet full survey details including questions, settings, and design
survey_createCreate a new survey draft✓
survey_editEdit the working draft✓✓
survey_publishPublish an existing draft or update a live survey from the working draft✓✓
survey_revert_draftReplace the working draft with the currently published version, discarding every unpublished edit✓✓
survey_duplicateClone a survey as a new unpublished draft✓
survey_deleteRemove a survey and its responses from the account (irreversible; rows are purged after an internal 14-day window)✓✓
survey_unpublishUnpublish a live survey and move it back to draft status✓✓

All survey tools require the surveys scope.

Response
ToolDescriptionWriteDestructive
responses_listList paginated responses with optional date and answer filters
responses_aggregatesPer-question aggregate statistics (counts per answer)
responses_crosstabCross-tabulate answers between two non-matrix survey questions
responses_survey_statsSurvey statistics — views, starts, completions, incompletes, avg. duration
responses_deleteDelete responses by ranked row numbers or delete all✓✓
responses_exportExport responses as CSV, XLSX, JSON, or SPSS
responses_export_chartsExport 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.

ModeCommandWhen
OAuth 2.1 (PKCE)empirio loginInteractive developer workstations
API keyEMPIRIO_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

FlagEffect
--jsonEmit machine-readable JSON instead of pretty tables
--quietSuppress 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).

ParameterTypeRequiredDescription
--scopestringNoRequested 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.

ParameterTypeRequiredDescription
--statusdraft | published | allNoFilter by publication status. Default: all.
--limitinteger (1–100)NoPage size. Default: 50.
--offsetinteger (≥ 0)NoZero-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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey 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).

ParameterTypeRequiredDescription
--modemanual | aiYesCreation mode.
--ai-promptstringIn AIPrompt that describes the survey to generate (AI mode only).
--questionsJSON arrayNoPre-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.
--metadataJSON objectIn manualSurvey-level metadata (title, display_mode). title is required in manual mode; in AI mode every field here overrides the generated value.
--welcome-pageJSON objectNoWelcome page configuration.
--end-pageJSON objectNoEnd page configuration.
--designJSON objectNoDesign customization (template, primary color, logo).
--settingsJSON objectNoSurvey behavior settings (including master_locale and auto_advance).
--scheduleJSON objectNoThe 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-rulesJSON arrayNoLogic 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.
--translationsJSON objectNoPer-locale translations. Question translations are keyed by a question's ref, not by question_id.
--idempotency-keyUUIDNoOverride 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_create is additionally capped at 10 / hour and 50 / day per account. On overflow the call returns 429 with Retry-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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey to edit.
--modemanual | aiYesEdit mode.
--ai-promptstringIn AIPrompt describing the change (AI mode only).
--question-operationsJSON arrayNoPer-question ops: add / update / delete / reorder. An add may carry a ref so later operations in the same call can point at it.
--option-operationsJSON arrayNoPer-option ops inside a question.
--metadataJSON objectNoPatch survey title / display_mode.
--welcome-pageJSON objectNoReplace welcome-page config.
--end-pageJSON objectNoReplace end-page config.
--logic-rule-operationsJSON arrayNoLogic-rule operations (add, update, delete). Max 200 operations per request; a survey holds at most 200 logic rules in total.
--designJSON objectNoDesign override.
--settingsJSON objectNoSurvey behavior settings, including auto_advance for respondent auto-navigation.
--translationsJSON objectNoPer-locale translation patches.
--scheduleJSON objectNoThe 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-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey to publish.
--idempotency-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey to unpublish.
--idempotency-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey whose draft is replaced.
--idempotency-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSource survey.
--idempotency-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey to delete.
--idempotency-keyUUIDNoOverride 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey whose responses to list.
--date-fromISO 8601NoFilter start.
--date-toISO 8601NoFilter end.
--sinceISO 8601 date-timeNoExclusive 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.
--orderdesc | ascNoEmission 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-participationsbooleanNoInclude incomplete participations with saved progress. Default false.
--limitinteger (1–1000)NoDefault 100.
--offsetinteger (0–100000)NoZero-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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey to aggregate.
--question-idscomma-separated listNoLimit 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey.
--question-xquestion_idYesRow question.
--question-yquestion_idYesColumn 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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey.
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.

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey.
--modeids | rows | allYesTargeting mode.
--response-idscomma-separated resp_… / part_…In ids moderesponse_id values from responses list (max 1000). Cannot be combined with --row-numbers.
--row-numberscomma-separated integersIn rows modeRanks from responses list (1-based, max 1000).
--date-from / --date-toISO 8601NoMust mirror the responses list filter used for rank resolution. Ignored in ids mode.
--include-incomplete-participationsbooleanNoMust mirror the responses list filter. Ignored in ids mode.
--idempotency-keyUUIDNoOverride 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".

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey.
--formatcsv | xlsx | json | spssNoDefault csv.
--date-from / --date-toISO 8601NoFilter window.
--include-incomplete-participationsbooleanNoDefault false.
--time-zoneIANA TZNoe.g. Europe/Berlin for exported timestamps.
--answer-filtersJSON arrayNoFilter by specific answer values (max 100).
--idempotency-keyUUIDNoOverride 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).

ParameterTypeRequiredDescription
--survey-idUUIDYesSurvey.
--formatpptx | pptx_images | pdf | docx | zip_images | chart_jsonNoDefault pptx. Binary formats return a 1h signed download URL; chart_json returns inline content.
--date-from / --date-toISO 8601NoFilter window.
--include-incomplete-participationsbooleanNoDefault false.
--time-zoneIANA TZNoe.g. Europe/Berlin.
--answer-filtersJSON arrayNoSame as responses export.
--idempotency-keyUUIDNoOverride 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