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 on the Business plan and above.
Authorization: Bearer ts_live_…
Endpoints
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/surveys | List 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. Each survey's responseCount, and responseCountLeavesOut where its total leaves some responses out, are as GET /api/v1/surveys/{id} gives them. |
| POST | /api/v1/surveys | Create 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. pausedReason is out-of-answers when the only thing stopping it is a plan out of answers (collectionStatus is then not-collecting), and null otherwise; the list carries both too. responseCount is every response but a withdrawal or a test; on a team survey with a minimum reporting group, when the key's owner is not its creator or the team owner and its screened-out or over-quota count is under the minimum, it counts completed and in-progress responses only and responseCountLeavesOut reads "screened-out and over-quota not shown" (the list carries it too). |
| POST | /api/v1/surveys/{id}/responses | Feed 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 }; a response a screen-out rule ends is stored as Screened out, as the web link stores it, and returns 201 with { responseId, status: "screened_out", screenedOutBy } (the rule's name); 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}/responses | Page 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, location, answers } (see Responses, below). |
| GET | /api/v1/surveys/{id}/responses/table | Every completed response as one CSV table, the same columns as the CSV download on the Responses page, for Power BI, Excel's Power Query and other BI tools. No paging. ?layout=options or ?layout=codes as on that download; ?tz= an IANA time zone for the dates (your account's zone otherwise; an unknown zone is a 400). A read-only key is enough, and is the one to give a BI tool. See Power BI and Excel, 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, and naText for its Not applicable choice (answered { "na": true }); 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 type | Value |
|---|---|
| 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> } · Not applicable: { "na": true }, when config.naText is set |
| 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 · Signature | Made on the survey page only — leave the answer out of a feed POST. |
| Descriptive text | Display-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": "WEB",
"location": {
"country": "AU",
"countryName": "Australia",
"region": "Victoria",
"city": "Melbourne",
"unknown": false
},
"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.
- location
- Where the respondent was, approximately, worked out from their IP address by Cloudflare when they answered through a link: { country, countryName, region, city, unknown } — country the two-letter code (AU), countryName its name (Australia), region and city null when not known. unknown is true when the place was recorded but Cloudflare could not tell the country (country and countryName are then null). location is null when nothing was recorded: an anonymous link, a link with location recording turned off, a response keyed in, fed through the API or imported, or one collected before locations were recorded. The IP address is never sent.
- 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 a page that is not full. A last page that is exactly full still carries one, and the page after it is empty with null. 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 }, with status "screened_out" and screenedOutBy when a screen-out rule ended the response.
- 400
- The request could not be read at all: malformed JSON, a missing body, or a malformed query parameter (cursor, since, a limit that is not a whole number of 1 or more, or on the response table a layout or tz it does not know).
- 401
- No key, an unknown key, or a revoked key. The WWW-Authenticate header names the scheme: Bearer.
- 403
- A read-only key on a call that creates or changes something (a POST): a key made as Read only on Account › API & integrations may make GET calls only. The error reads: This API key is read-only. Make a key that may read and change, in Account › API & integrations. Also an account whose email address is not confirmed yet.
- 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, until it comes back null: a page that is not full ends it, so when the last page is exactly full one more call returns an empty page with null. ?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.
Power BI and Excel
GET /api/v1/surveys/{id}/responses/table returns every completed response as one CSV table, so Power BI and Excel's Power Query can load it and refresh it on a schedule. In Power BI choose Get data › Blank query, open the Advanced Editor and paste the query below with your survey id and key. When Power BI asks how to connect, choose Anonymous: the key travels in the header. In Excel it is Data › Get Data › From Other Sources › Blank Query.
let
Source = Web.Contents("https://uat.thisysloop.com/api/v1/surveys/<survey id>/responses/table",
[Headers = [Authorization = "Bearer <your key>"]]),
Table = Csv.Document(Source, [Delimiter = ",", Encoding = 65001, QuoteStyle = QuoteStyle.Csv]),
Promoted = Table.PromoteHeaders(Table, [PromoteAllScalars = true])
in
PromotedKeep the key out of shared reports: anyone who can edit the query can read it. A key made for one dashboard can be revoked on its own.
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, a Slack Block Kit message for a Slack incoming webhook, or an Adaptive Card for a Microsoft 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 reads back for the survey's creator and the team owner, and a teammate who adds a webhook sees it once, as it is added). 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
2xxwithin 10 seconds. Anything else is retried twice more — three attempts in all. The first goes the moment the event happens; each retry goes on our next scheduled pass, usually within a minute or two. Every delivery is queued before it is sent, so a restart on our side does not lose one. - If the account's plan ends, its webhooks keep sending for 14 days, then pause: nothing is sent, and each delivery held back says so in the log, until a plan that includes webhooks is chosen again.
- The address must start with
https://: a delivery carries answers, and they are never sent unencrypted. A plainhttp://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, and above them anything still queued or waiting to retry. Redeliver sends that same body again, once, to the webhook's current address, under a fresh
X-Thisys-Signaturetimestamp. The body is byte for byte the original, so make your handler idempotent: a redeliveredresponse.completedcarries 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.