Skip to main content

Help Centre

Developer reference

Create surveys and feed responses in from your own code. Everything the API accepts and returns, on one page.

Authentication

Every request carries a key you made in Account › API & integrations. Keys are shown once, at creation, and start ts_live_. Keys are included from the Business plan.

Authorization: Bearer ts_live_…

Endpoints

MethodPathWhat it does
GET/api/v1/surveysList the surveys you can open — your own, and your teams' surveys you have access to — newest first, 100 a page (?limit= a whole number from 1; read as 200 above that; anything else is a 400). The body has total — how many of them there are in all, team surveys included — and nextCursor: pass it back as ?cursor= for the next page; it is null on the last one.
POST/api/v1/surveysCreate a survey from a definition — the payload's pages use the same shape as a template. Returns the structure (ids and answer shapes) on 201.
GET/api/v1/surveys/{id}Read one survey: every page, question and option with its id, and an answerShape per question saying what its value must look like. collectionStatus says whether it takes answers now: collecting (a web link or the API feed is open), not-collecting, or closed. status is the stored state, which stays DRAFT on a survey that is collecting, so read collectionStatus.
POST/api/v1/surveys/{id}/responsesFeed one complete response. Held to every rule the survey page enforces: validation, required, skip logic. The survey must be taking answers: it has an open web link, or you have opened its API feed (the survey's Collect tab: New collector, then API feed); until then the feed answers 409 and stores nothing. Send an Idempotency-Key so a retry never double-counts. Returns 201 with { responseId }; the same Idempotency-Key again within 24 hours returns 200 with the same responseId and an Idempotent-Replayed: true header (see Responses, below).
GET/api/v1/surveys/{id}/responsesPage completed responses oldest-first, 100 a page (?limit= a whole number from 1; read as 200 above that; anything else is a 400); pass each page's nextCursor back as ?cursor=. ?since=<ISO> is the looser "after this instant" form. Each response is { id, completedAt, collectorId, source, answers } (see Responses, below).

First read the ids: GET /api/v1/surveys/{id} returns structure — every page, question and option with its id, and an answerShape per question. Then feed: POST /api/v1/surveys/{id}/responses with { "answers": [{ "questionId": "<id>", "value": <shape> }] } records one complete response. Fed responses land in their own “API feed” collector, count against your plan like any response, and your Act rules and webhooks run on each one — with anything critical still confirmed by a person there.

Creating a survey

POST /api/v1/surveys with a name and its pages. A 201 returns the new survey with its structure — every question and option id you need to feed responses.

{
  "name": "Customer check-in",
  "description": "Two minutes, after each visit.",
  "pages": [
    {
      "title": "Your visit",
      "questions": [
        {
          "type": "NPS",
          "text": "How likely are you to recommend us to a friend?",
          "required": true
        },
        {
          "type": "MULTIPLE_CHOICE",
          "text": "What brought you in?",
          "options": [
            "Coffee",
            "Food",
            "Meeting someone"
          ],
          "allowOther": true
        },
        {
          "type": "RATING_SCALE",
          "text": "How was the service?",
          "config": {
            "min": 1,
            "max": 5,
            "minLabel": "Poor",
            "maxLabel": "Excellent"
          }
        },
        {
          "type": "OPEN_TEXT",
          "text": "Anything we should change?"
        }
      ]
    }
  ]
}
name
Required. Up to 200 characters.
description
Optional. Up to 2,000 characters.
pages[]
At least one page: { title?, description?, questions[] }.
questions[].type
One of the types below. NPS and SLIDER are accepted as presets of RATING_SCALE.
questions[].text
Required. Up to 1,000 characters.
questions[].options
Strings (up to 100), or { text, isCorrect } to mark a right answer. Required for choice, dropdown, checkbox, ranking and matrix rows.
questions[].required · allowOther · randomizeOptions
Optional booleans, default false.
questions[].config
Optional per type: min/max and minLabel/maxLabel for a scale; columns for a matrix; dateMode for a date; validation.format for open text (email, phone, url, wholeNumber, decimal or date).
questions[].showIf
Optional. When the question is shown, naming an earlier question by its number (descriptive text is not numbered): { q: 3, is: "Yes" }, or is: ["A", "B"] for either answer; { q: 1, atMost: 6 } or { q: 1, atLeast: 9 } for a rating.
questions[].screenOutIf
Optional, on a choice question. The answer, or up to five answers, that end the survey for that respondent (screened out). It comes after the other questions on its page, and a page must follow it.
questions[].screenOutMessage
Optional, beside screenOutIf. What a screened-out respondent reads at the end, up to 1,000 characters; the first one in the definition is the survey's. Without it they read the built-in screened-out message.

Question types: MULTIPLE_CHOICE, DROPDOWN, RATING_SCALE, MATRIX, OPEN_TEXT, RANKING, FILE_UPLOAD, DATE, SIGNATURE, CHECKBOX, MATRIX_MULTI, DESCRIPTIVE_TEXT, MULTIPLE_TEXTBOXES, NUMERICAL_TEXTBOXES, DEMOGRAPHIC, MATRIX_DROPDOWN, plus the presets NPS and SLIDER.

Answer shapes, by question type

The value for each answer, by the type the builder names. A wrong shape is a 422 that names the question.

Question typeValue
Choice (one) · Dropdown{ "optionId": "<option id>" } · Other: { "other": true }, with "otherText": "…" beside "value"
Choice (many){ "optionIds": ["<option id>", …] } · add "other": true, with "otherText": "…" beside "value"
Rating · NPS · Slider{ "rating": <integer in config.min..max> }
Open text{ "text": "<string>" }
Date{ "date": "YYYY-MM-DD" } · time mode { "time": "HH:MM" } · date+time { "datetime": "YYYY-MM-DDTHH:MM" }
Matrix (one per row){ "cells": { "<row option id>": "<column label>" } }
Matrix (many per row){ "cellsMulti": { "<row option id>": ["<column label>", …] } }
Matrix of drop-downs{ "cellsGrid": { "<row option id>": { "<column label>": "<menu choice>" } } }
Ranking{ "rankedOptionIds": ["<option id>", …] }
Multiple textboxes{ "texts": { "<row option id>": "<string>" } }
Numerical textboxes{ "numbers": { "<row option id>": <number> } }
Demographic{ "fields": { "<field key>": "<string>" } }
File upload · SignatureMade on the survey page only — leave the answer out of a feed POST.
Descriptive textDisplay-only; it takes no answer.

Responses

POST /api/v1/surveys/{id}/responses answers 201 with the new response's id. Sent again with the same Idempotency-Key within 24 hours, it answers 200 with the same id and an Idempotent-Replayed: true header — nothing is recorded twice.

{
  "responseId": "cmv…"
}

GET /api/v1/surveys/{id}/responses answers 200 with a page of completed responses, oldest first:

{
  "responses": [
    {
      "id": "cmv…",
      "completedAt": "2026-10-10T02:15:04.000Z",
      "collectorId": "cmc…",
      "source": "API",
      "answers": [
        {
          "questionId": "cmq1…",
          "value": {
            "rating": 9
          },
          "otherText": null,
          "comment": null
        },
        {
          "questionId": "cmq2…",
          "value": {
            "optionId": "cmo…"
          },
          "otherText": null,
          "comment": null
        }
      ]
    }
  ],
  "nextCursor": "eyJ…",
  "nextSince": "2026-10-10T02:15:04.000Z"
}
id
The response's id — the same one a webhook and the POST that fed it give.
completedAt
When it was completed (ISO 8601, UTC). Collected through an anonymous link, the day only (2026-10-10), in your account's time zone.
collectorId
The collector it came through: a web link, an email invitation, manual entry, an import, or the API feed.
source
How it arrived: WEB, MANUAL, API or IMPORT.
answers[]
{ questionId, value, otherText, comment } — value in the shape its question takes (Answer shapes, above); otherText the words beside an 'Other'; comment a question's comment box. Both null when empty.
nextCursor · nextSince
Where the next page starts: pass nextCursor back as ?cursor=; it is null on the last page. nextSince is the same point as an instant, for ?since=.

Status codes

In full, because branching on a status you have guessed at is how a feed silently rots.

200
Read — a GET's body. Also the answer to a feed POST replayed with an Idempotency-Key already used in the last 24 hours: the same { responseId }, with Idempotent-Replayed: true.
201
Created — POST /surveys returns the structure with ids and answer shapes; POST /surveys/{id}/responses returns { responseId }.
400
The request could not be read at all: malformed JSON, a missing body, or a malformed query parameter (cursor, since, or a limit that is not a whole number of 1 or more).
401
No key, an unknown key, or a revoked key. The WWW-Authenticate header names the scheme: Bearer.
404
No such survey — or not yours.
405
The endpoint does not take that method (a DELETE on a survey, say). The Allow header lists the ones it does — the same list an OPTIONS request answers with.
409
The survey is closed or not taking answers yet (no open web link, and its API feed not opened on the Collect tab), its API feed is closed or has reached its response limit, or an earlier request with the same Idempotency-Key is still in flight (retry after Retry-After).
422
The request was read and is wrong: an unknown question type, a missing field, a choice question without options, an answer of the wrong shape or with a key no shape reads, an 'Other' with no text, a question answered twice, an unknown or deleted questionId, an Idempotency-Key reused for a different request. The body's issues name each one: { path, message }, with the questionId on an answer's. An answer the survey's rules refuse also comes with fieldErrors — the same messages by questionId, kept for integrations that read them.
429
Over the rate limit. Retry-After says how many seconds to wait; X-RateLimit-Remaining is 0.

Every error has a JSON body: error, a sentence, and — on a 422 — issues, one per problem, each with the path into your request and a message:

{
  "error": "Some answers need attention.",
  "issues": [
    {
      "path": "answers.0.value",
      "message": "Pick a rating between 0 and 10.",
      "questionId": "<question id>"
    }
  ]
}

Paging a feed

GET /api/v1/surveys/{id}/responses?limit=200 pages completed responses oldest-first, 100 a page unless ?limit= says otherwise: a whole number from 1, read as 200 above that (anything else is a 400). Pass each page's nextCursor back as ?cursor= to walk the stream without gaps or duplicates. ?since=<ISO> is the looser “after this instant” form. A response collected through an anonymous link gives its completedAt as its day (2026-09-24) in your account's time zone, not its time; the cursor after it holds only that day, and nextSince is the start of that day. Webhook payloads give the same day.

GET /api/v1/surveys pages the same way: newest first, 100 a page (?limit= a whole number from 1, read as 200 above that — the same rule), with nextCursor to pass back as ?cursor= until it comes back null, and total — every one of the surveys you can open — your own, and your teams' surveys you have access to — so you can check nothing was missed.

Rate limits

Each key gets 1,200 feed calls and 120 survey calls an hour. Every answer to a call made with a key says where it stands, so a client can pace itself before it is refused; a 429 says you are over, with Retry-After in seconds.

X-RateLimit-Limit
Calls this key may make in the window, on this endpoint's budget.
X-RateLimit-Window
The window, in seconds (3600). It rolls: each call counts for an hour from when it was made.
X-RateLimit-Remaining
Calls left in the window now, this one counted.
X-RateLimit-Reset
Seconds until the oldest call in the window ages out and gives one call back. On a 429, the same as Retry-After.

Webhooks

A survey can POST to up to five of your URLs when something happens to its responses. Add them on the survey's Collect tab under Webhooks, choosing the events and the format: signed JSON for your code, or a plain message for a Slack or Teams incoming webhook.

Events

response.completed
A response is completed (with its answers)
response.flagged
A response is flagged by the quality checks
response.pii_found
A response contains personal data
assessment.passed
A respondent passes the assessment
assessment.failed
A respondent falls short of the pass mark
response.withdrawn
A respondent withdraws their response (id only — delete your copy)
response.deleted
Responses are deleted — by you, a teammate or the retention window (ids only)

response.completed carries the answers. The flag, personal-data and assessment events carry a reference only — the answers went out once already, and a personal-data alert must not repeat the data it reports. response.withdrawn and response.deleted carry ids only: when one arrives, delete your copy of those responses. A large deletion arrives in deliveries of up to 1,000 ids.

Send test beside a webhook on the Collect tab posts a signed webhook.test event — the survey's id and name, response: null — so you can check your endpoint and its signature check before a real response arrives. Its result shows as the webhook's last delivery. Edit changes the address and the events and keeps the secret.

Payloads

{
  "event": "response.completed",
  "survey": {
    "id": "cmu…",
    "name": "Customer check-in"
  },
  "response": {
    "id": "cmv…",
    "completedAt": "2026-09-24T02:15:04.000Z",
    "answers": [
      {
        "questionId": "cmq…",
        "questionText": "How was it?",
        "value": {
          "optionId": "cmo…"
        },
        "otherText": null
      }
    ]
  }
}
{
  "event": "response.deleted",
  "survey": {
    "id": "cmu…",
    "name": "Customer check-in"
  },
  "occurredAt": "2026-09-24T02:20:11.000Z",
  "responses": [
    {
      "id": "cmv…"
    }
  ]
}

Verifying a delivery

Every JSON delivery has Content-Type: application/json and an X-Thisys-Signature header of the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of `${t}.${rawBody}` keyed with the webhook's secret (shown on the Collect tab, starting whsf_). It is the same scheme Stripe uses. Check it against the raw body, before parsing:

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received, before any JSON parsing.
function verify(rawBody, header, secret) {
  const [, t, v1] = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? "") ?? [];
  if (!t) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // reject replays older than 5 minutes
  return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Delivery

  • Answer with any 2xx within 10 seconds. Anything else is retried twice more with a short backoff — three attempts in all.
  • The address must start with https://: a delivery carries answers, and they are never sent unencrypted. A plain http:// address is refused when you add or edit it, and at every delivery.
  • Redirects are not followed, and a URL that resolves to a private or internal address is refused — at creation and again at every delivery.
  • After 10 failed deliveries in a row the webhook turns itself off, and the survey's owner is told in the notifications bell (and after 3, that it keeps failing). The Collect tab shows the last status and error; turning it back on resets the count.
  • Every attempt is logged for 30 days. Deliveries beside a webhook on the Collect tab lists its latest 50 — the event, when, the status your endpoint returned and how long it took — with the body exactly as sent. Redeliver sends that same body again, once, to the webhook's current address, under a fresh X-Thisys-Signature timestamp. The body is byte for byte the original, so make your handler idempotent: a redelivered response.completed carries the same response id.
  • Deliveries are not ordered across events. Use the response id to tie them together.

Questions the reference does not answer? Contact support.