Skip to main content
The Surveys API lets your systems run the same NPS, CSAT, CES and custom surveys you build in the app. You can create and launch a survey, request invitations, record answers your own product collected, and read every response back. This page explains the concepts. The endpoint pages in the API reference list every field. For surveys shown inside your product, see In-app survey SDK.

Before you start

  • Every Surveys endpoint needs a secret key (gt_live_). Publishable keys (gt_pub_) are refused with 403.
  • Reading needs the read:surveys scope. Creating, editing, launching, inviting, recording and changing rules need write:surveys. See Authentication.
  • Lists use limit (default 50, max 100) and offset. There is no cursor.
  • Each key can make 60 requests per minute. Past that, the API returns 429 with the code rate_limit_exceeded. Back off and retry.
  • Errors use the standard envelope. A body that isn’t valid JSON is 400. A value that fails its own field’s validation is 422. Rules that compare several fields are different: on PATCH /surveys/{id} and when you launch a survey, a failure (a missing first question, an NPS scale outside 0-10, a CSAT or CES scale outside 1-5 or 1-7, promotional copy, showWhen rules) returns 400 bad_request with a generic message and no field detail. Answers that don’t fit their questions, or a subject that can’t be linked, on POST /surveys/{id}/responses are 422. The session endpoint returns 422 for any invalid or malformed body. Rules the survey enforces, such as editing locked questions, are 400. See Errors.

Measures and scales

A survey has one type, which the API returns as measure. For nps, csat and ces, the first question carries the score. It must be a required rating or scale question. A survey can have up to 20 questions. Each has a type: Choice questions need at least two different options. A multiple-choice question can set preset: "pmf" to ask the product-market fit question with its four standard answers. A question can use showWhen to appear only after a matching earlier answer, such as “ask why” when the first score is 6 or lower. showWhen names an earlier question by its zero-based index (question, the first question by default), an operator and a value. The operators depend on the question it reads: Surveys must stay purely feedback. Copy with promotional phrases such as “discount code” or “limited time” is rejected, so the survey can be sent without marketing consent.

Bands

Every response gets a band. Use it to route feedback without knowing the scale. A response with no score, and every response to a custom survey, is unscored.

Survey lifecycle

1

Create a draft

POST /surveys creates a survey with status: "draft". Nothing is collected yet.
2

Launch it

POST /surveys/{id}/status with { "status": "active" } validates the survey again and starts collection. GainTrace sends the survey.launched webhook.
3

Close or reopen it

Send closed to stop collecting and active to start again. A launched survey can’t go back to draft.

What you can still change

PATCH /surveys/{id} edits any part of the survey while it is a draft. Once a survey is active, or has any responses, three things lock: type, questions and settings.anonymous. Changing one returns 400 with the message “Duplicate this survey to change questions or anonymity after launch.” This keeps old answers meaningful. Name, cooldown, thank-you copy, reminders, send window, in-app rules and the other settings stay editable. When you send settings, each top-level key you include replaces that key and the rest are kept. Nested objects such as reminders are replaced as a whole.

Channels

A response arrives through one channel. The survey_response webhooks name it in channel. The values link, qr and kiosk are historical only. They label answers collected before public links, QR codes and kiosks were removed, and no new answer arrives through them. A survey object has no links field: every answer comes through a personal entry point. Invitation emails link to short personal addresses such as https://app.gaintrace.com/s/aB3dE5gH7jK9. While the white-label add-on is active and your survey domain is verified, they use your own domain. Links already sent keep working. The REST API stays on the GainTrace address. Answers that arrive from an imported survey tool (see Import from other survey tools) use the channel import on webhooks. Signed invitation links and identified in-app sessions tie each answer to a person and company. A survey page opened without a personal link shows “This survey only takes answers from a personal invitation. Ask the team that sent it for your link.” and records nothing. A survey can name up to 10 hiddenFields, such as plan. When a personal link carries that parameter, for example ?plan=pro, the value is saved with the response. Parameters the survey doesn’t name are dropped.

Record responses from your own system

Use POST /surveys/{id}/responses when your product, support tool or bot already collected the answer. The response goes through the same validation, attribution, signals, Flows, timeline and webhooks as an emailed survey.
  • The survey must be active. A draft or closed survey returns 404.
  • personId must be an active person in your workspace.
  • answers are keyed by question index: q0, q1 and so on. Required questions that are visible must be answered, and each value must fit its question.
  • score is the first question’s score. If you also send answers.q0, the two must match.
  • hidden is an optional object of hidden-field values, such as { "plan": "pro" }. Only names the survey lists in hiddenFields are saved.
  • subject is an optional record the answer is about. See Link a response to a record.

Idempotency

externalId is your own stable id for the answer, up to 255 characters. GainTrace turns it into a deterministic key for that survey. If you send the same externalId for the same survey again, you get the first response back with duplicate: true and status 200. The new body is ignored and nothing is created twice. The first call returns 201 with duplicate: false. Use a stable id from your system, such as a ticket id. It’s safe to retry after a timeout. On an anonymous survey, the response is stored without a person or company. A response can be about one record: a deal, a ticket, a Company’s plan or a record in a custom data model. Send a subject and the response shows up on that record, and you can filter responses by it. subject takes one of these shapes: You can send subject in three places:
  • the body of POST /surveys/{id}/responses
  • the body of POST /surveys/{id}/session, for an in-app survey
  • the subject claim of a signed in-app token (see In-app survey SDK)
The public respondent endpoints never accept a subject, so a respondent can’t change it. GainTrace checks the record before it saves anything. The record must exist in your workspace and still be live. If both the person and the record belong to a Company, it must be the same Company. If an externalId matches more than one ticket, add source or use the GainTrace id. Each failure returns 422 with a message that says which check failed and how to fix it. A response to an anonymous survey doesn’t keep its subject. Responses carry subject as { "type", "id" }, or null, on the API and in webhooks. Filter GET /survey-responses with subjectType and subjectId. subjectId needs subjectType.

Invitations and approval

POST /surveys/{id}/invitations requests email invitations. Send exactly one of these in the body: A segment or audience can match up to 1,000 people in one request. A larger audience returns 400. Narrow the rules or split it into segments. What happens next depends on the workspace’s invitation review setting, which admins change in Sending and identity in Surveys. You can’t set it through the API.
  • Send (the default). Invitations go out at once, or at the survey’s next allowed hour if it has a sendWindow.
  • Approve. Each invitation joins the Triage approval queue. A teammate approves it, and only then does the email go out. Approvals expire after 14 days.
Invitations requested through the MCP server always wait for approval, whatever the setting. If the survey has reminders, people who haven’t answered get a reminder after afterDays days, up to max times. People who answered are never reminded. The survey must be active, and every person must be active with an email address and a company. If any person in personIds fails that check, the whole request returns 400 and nothing is queued. People are skipped, not failed, in three cases:
  • An invitation for the same email is already waiting, scheduled or just released.
  • The person is inside the survey’s cooldownDays. That counts the last survey of the same measure they got: an email sent, an invited answer or an in-app view.
  • The person was surveyed within the workspace frequency rule.
The request returns 202 with counts:
A segmentId or audience request adds matched, the number of people the rules found. To preview a list before anything is queued, use the gt_survey_request_invitations tool in the MCP server. It defaults to a dry run that reports who would be invited and why anyone is skipped.

Target people with segments

A segment is a saved set of Company and People rules. Use one to invite the same group again, or to choose who sees an in-app survey.
  • GET /survey-segments lists the workspace’s segments.
  • POST /survey-segments saves one and returns 201. Send a name (unique in the workspace, ignoring case) and rules, an object with a company group and a person group. Each group has a logic of and or or and up to 20 rules. At least one rule is required.
  • DELETE /survey-segments/{id} archives a segment and returns 204. Surveys that still point at it stop matching anyone.
Reading needs read:surveys and writing needs write:surveys, both with a secret key. A rule is { field, operator, value }. People rules can use email, name, role, contactRole, isChampion, lastSeenAt, engagementScore, createdAt, or traits.<name> for a trait on the person. Any other People field returns 422. Company rules use Company data-model fields.
For in-app surveys, a survey can also target pages and moments. See Targeting.

Frequency and follow-up rules

Two workspace-wide rules keep people from being over-surveyed and make sure feedback gets an owner.

Frequency rule

GET and PUT /surveys/frequency-rule read and replace it. recontactDays and dismissedRecontactDays accept 0 to 365, and 0 turns the rule off. In companyLimit, people is 1 to 1,000 and days is 1 to 365. People are matched by email address, so a person counts once however often they are asked, and reminders don’t count. Leave companyLimit out of a PUT to keep the saved limit. Send null to turn it off. An invitation skipped by the limit counts in skipped, and an in-app session for a person it blocks returns eligible: false with the reason company_limit. For in-app surveys, a person’s recent surveys and dismissals count across every Company they belong to. Each survey also has its own cooldownDays (0 to 730). It sets the minimum days since the person last got a survey of the same measure (an email sent, an invited answer or an in-app view) before this survey can invite them or show to them in-app. It doesn’t block responses you record through the API.

Response rules

GET and PUT /surveys/case-rules read and replace how new responses are handled. Both PUT calls need every field, except companyLimit on the frequency rule. Saving one rule doesn’t change the other.

Webhooks

Subscribe a webhook endpoint to survey events instead of polling. The payloads and signature check are on Webhooks. A survey_response.created event from an email or in-app survey can carry only the score. The comment and other answers arrive in a later survey_response.updated. survey.launched and survey.closed carry id, name, measure, status, previousStatus and changedAt. A status call that doesn’t change the status fires nothing.

Read results

  • GET /survey-responses lists responses from every native survey, newest first. Filter by surveyId, companyId, measure, band, status (new, in_progress, resolved, open meaning not resolved, or overdue meaning open past your hours-to-close rule), theme, subjectType with subjectId, and a from and to window on submission time. Filters combine, and an unknown parameter returns 422.
  • theme limits the list to responses whose written feedback was tagged with one of 14 themes: onboarding, reliability, performance, usability, missing_functionality, integrations, support_quality, documentation, pricing_value, billing_admin, security_compliance, account_management, reporting_analytics or mobile.
  • GET /survey-responses/{id} reads one response.
  • GET /surveys and GET /surveys/{id} read survey definitions and status.

Company scores and coverage

A Company’s NPS, CSAT and effort score count each person once, under one workspace rule:
  • GET /surveys/rollup-rules and PUT /surveys/rollup-rules read and set that rule: each person’s latest answer, or the mean or median of their answers, inside windowDays, with optional roleWeights for decision makers, executive sponsors and champions.
  • GET /companies/{id}/survey-scores returns one Company’s scores under the rule, how many people each rests on, whether a decision maker answered, and a method sentence to show beside the numbers.
  • GET /surveys/coverage returns who you hear from: Companies and ARR with a scored answer in the window, at how many a decision maker answered, and Companies renewing in the next 90 days with no feedback in 180 days. ARR is in the workspace reporting currency; currencies with no exchange rate yet are listed in unconvertedCurrencies and left out.
Imported NPS scores count toward both. A response carries the same fields as the survey_response webhook payload: score, scaleMax, band, comment, answers, companyId, personId, subject, status and submittedAt. Reviewer notes never leave GainTrace.

Survey templates

Use GET /survey-templates to list the templates your workspace saved, each with its full survey definition (scope read:surveys). Use POST /survey-templates to save one (scope write:surveys). Send name, an optional folder, and either surveyId to save an existing survey or input with a survey definition. It returns 201 with the template. Both need a secret server key: a gt_pub_ key returns 403. A name another template uses returns 422.

Logic, jumps and endings

A question can carry logic, jumps and libraryId, and settings.endings holds named thank-you screens.
  • logic is { "match": "all" | "any", "conditions": [...] } with 1 to 10 conditions. A condition is either an earlier answer, { "question", "operator", "value" }, or a hidden field, { "hidden", "operator": "is" | "is_not", "value" }. Answer operators are lte, gte, eq, is, is_not, includes, excludes, before, after and band. Don’t send logic and showWhen together.
  • jumps is up to 10 rules, each { "when": <logic group>, "to": { "question": n } | { "ending": id } | { "end": true } }. The first matching rule wins.
  • settings.endings is up to 10 entries with id, name, and optional title, message and link.
  • libraryId links a question to the workspace question library.

Languages

A survey is written in a defaultLanguage and can offer translations. Both live in settings, so you set them with POST /surveys and PATCH /surveys/{id}. You can change them after launch.
  • settings.defaultLanguage is one of 44 language codes, such as en, fr, de, pt-BR, zh-TW or ar. Without it, the default is English.
  • settings.translations is an object keyed by language code, with up to 15 entries. The default language’s entry holds only ui overrides.
Each translation can carry a page title, a questions array, a thankYou object, an endings object keyed by ending id (each with title, message and linkLabel) and ui overrides for the buttons and labels around the survey.
In questions, the entry at each position translates the question at the same position. Use null to leave a question in the default language. An entry can translate text, description, linkLabel, options, rows and scaleLabels. GainTrace stamps each question translation with a source fingerprint of the default copy when you save. A translation shows only while the default copy is unchanged. If you edit a question’s default copy, respondents see the default copy until you update the translation. Respondents see the language picked from, in order:
  1. The language the request asks for: the ?lang= parameter on the survey link, such as /survey/{id}?lang=fr, or lang in an in-app session request.
  2. The person’s locale trait, then their language trait, on personal links, in-app surveys and email.
  3. The browser’s language.
  4. The survey’s defaultLanguage.
A code matches exactly, or by its base language, so fr-CA shows fr. The survey link page sets the page language, and lays out right to left for Arabic, Hebrew, Persian and Urdu. Choice answers are stored against the default-language options, so reports stay comparable. Each response records the language it was answered in. The API returns it as language, and warehouse exports carry it as language. The AI follow-up label and its disclosure aren’t translatable. They stay in English. In-app surveys and invitation emails use the same translations. See the in-app SDK guide for lang.

AI follow-up question

settings.aiProbe is { "enabled": true } or { "enabled": false }, and it is off unless you turn it on. When it is on, Trace AI can ask one optional follow-up question after a respondent’s feedback is saved. Respondents always see that the question was written by AI. The question and the answer come back inside the response as answers._probe, with question and answer (null until answered). It appears only when a follow-up was asked. survey_response.updated fires when the respondent answers it.

Import from other survey tools

GainTrace can pull answers from Typeform, SurveyMonkey, Qualtrics and Retently, and import CSV exports. Connect a tool in the app, under Surveys, Data sources. The API reads those connections and lets you pull one on demand. It doesn’t create connections or return credentials.
  • GET /survey-imports lists connected sources. Each has id, provider, name, externalSurveyId, measure, scaleMin, scaleMax, status (active, paused or error), credentialHint, connectedAt, lastRunAt, lastSuccessAt, lastError, nextRunAt and lastRun (imported, updated and skipped counts, or null). It needs read:surveys.
  • POST /survey-imports/{id}/runs pulls one source now, for up to 60 seconds, and needs write:surveys. It returns 404 for an unknown id, and 409 while another pull is running or when the source’s credential was rejected.
status is succeeded, partial (the pull stopped early and continues on the next pull) or failed. skipped is the total. skip_reasons lists up to 20 reasons with counts, and never includes email addresses. Imported answers are matched to People by email and keep their original dates. Pulls repeat safely, because each answer is identified by its provider and response id. History from before you connected arrives quietly, with no webhooks, Flows or alerts. Answers dated after you connected fire survey_response.created and survey_response.updated with the channel import. Imported NPS appears in GET /nps-responses (filter by provider). Imported CSAT and CES appear in survey reporting and Company scores, but not in GET /survey-responses.

Bulk export to your warehouse

For analytics in BigQuery or Snowflake, Enterprise workspaces can export every survey and response to tables in their own warehouse. See Survey warehouse export for the table schema.

Next steps

In-app survey SDK

Show a survey inside your product.

Webhooks

Get survey events as they happen.

Survey warehouse export

Query responses in BigQuery or Snowflake.
Last modified on October 20, 2026