Machine-readable description: /openapi/v1.json (OpenAPI 3). Liveness: /health.
Version 1. This document describes how to authenticate against the Index Data API, how requests and responses are shaped, and what each endpoint returns. It is written for the engineers integrating with the API.
The API gives an organisation read access to its Index survey data: the questions asked, the answers given, and the media and sentiment derived from free-text answers.
Base URL:
https://api.index.gslglobal.com
All endpoints are under /v1, use GET, and return JSON. TLS is required; plain HTTP is not served.
This document is served at /docs. A machine-readable OpenAPI 3 description is at /openapi/v1.json; it declares the endpoints, parameters, response schemas and the HTTP Basic security scheme, and can be used to generate a client.
| Endpoint | Returns | Paginated |
|---|---|---|
GET /v1/questions |
Questions that have at least one answer in the requested window | No |
GET /v1/responses |
Answers in long form: one row per response per question | Yes |
GET /v1/responses/wide |
Answers in wide form: one object per response, one key per question | Yes |
GET /v1/media |
Audio, video and image attachments with transcriptions | Yes |
GET /v1/sentiment |
Themes detected in free-text answers, with sentiment and confidence | Yes |
GET /v1/summary |
Counts of what the same request would return across all pages | No |
GET /health |
Liveness check, no authentication | No |
You are issued a client id and a client secret. GSL will supply you with a client secret and is not recoverable; if lost, a new one can be issued. Send both on every request under /v1 using one of two forms.
HTTP Basic (preferred). Standard Authorization header with the client id as the user name and the secret as the password:
Authorization: Basic base64(clientId:clientSecret)
Custom headers. Equivalent, for tooling that cannot set an Authorization header:
X-Client-Id: <clientId>
X-Client-Secret: <clientSecret>
If both are present the Authorization header is used. Credentials are never accepted in the query string.
A missing, malformed or incorrect credential returns 401 Unauthorized with a WWW-Authenticate: Basic realm="Index Data API" header and no further detail. Treat every 401 as "check the credential"; the API does not distinguish an unknown client from a wrong secret.
Secrets can be rotated or revoked on request. Allow up to one minute for a revocation to take effect.
A response is one completed survey, identified by an integer responseId that is stable and unique across the whole dataset. A question is identified by a string questionId (examples: OneThingChange, AccommodationExpectations.AvailabilityCommunal, and GUID-based ids for some question types). An accommodation (hall) is identified by a GUID.
Every response also carries four pseudo-questions that appear alongside real questions in the answer data:
| questionId | Value |
|---|---|
submitdate |
When the response was submitted, yyyy-MM-dd HH:mm:ss, UTC |
AccommodationName |
Name of the hall; the answerId on this row is the hall's GUID |
AccommodationCity |
City of the hall |
InstitutionName |
Name of the institution the respondent belongs to |
Responses are organised into waves, named survey periods such as 2026 Q2, each with a start date, an end date, and a set of halls. A response is in scope for your organisation when it was submitted to one of your halls during a wave in which that hall was part of your programme.
Every /v1 endpoint accepts the same query parameters. Names are case-insensitive.
| Parameter | Required | Format | Description |
|---|---|---|---|
from |
Yes | YYYY-MM-DD |
Inclusive start of the submit-date window |
to |
No | YYYY-MM-DD |
Inclusive end of the window. Defaults to today (UTC) |
wave |
No | text, up to 50 chars | Restrict to one named wave. Without it, every wave overlapping the window contributes |
pageSize |
No | integer | Responses per page. Default 500, maximum 5000, minimum 1. Ignored by /questions; used by /summary only to compute the page count |
cursor |
No | opaque string | Position of the next page, from the previous page's meta.nextCursor |
question |
No | list | Only these question ids. See section 6 |
accommodation |
No | list | Only responses from these halls, by GUID. See section 6 |
Constraints, all enforced with a 400 response naming the parameter:
to must be on or after from.pageSize must be at least 1; values above 5000 are silently clamped to 5000.cursor must be a value the API produced.accommodation values must be GUIDs.Every successful call returns 200 with this shape:
{
"data": [ ... ],
"meta": {
"from": "2026-02-01",
"to": "2026-03-30",
"wave": null,
"question": null,
"accommodation": null,
"count": 1000,
"nextCursor": "pVflEQ==",
"nextLink": "https://.../v1/responses?from=2026-02-01&to=2026-03-30&pageSize=1000&cursor=pVflEQ%3D%3D",
"generatedAt": "2026-09-04T10:22:19.437Z"
}
}
data is always an array, even for /summary which contains one element.meta.from, meta.to, meta.wave, meta.question, meta.accommodation echo the parameters as applied. to is filled in when you omitted it. The list filters are null when not supplied.meta.count is the number of elements in data for this page.meta.nextCursor and meta.nextLink describe the next page and are both null on the last page.meta.generatedAt is the UTC time the page was produced.Property names are camelCase. Nulls are emitted explicitly, never omitted. Dates are ISO 8601; timestamps are UTC with a Z suffix.
Paginated endpoints divide the responses in scope into pages of pageSize responses, ordered by responseId. A page always contains whole responses: all rows belonging to a response are on the same page, in every dataset. Rows within a page are ordered by responseId, then questionId.
To read everything, request the first page without a cursor, then repeat with cursor set to meta.nextCursor until nextCursor is null. meta.nextLink is the same thing as a ready-made absolute URL: this request with cursor replaced and every other parameter preserved.
Rules:
from, to, wave or a filter means starting again without a cursor./responses./questions and /summary are not paginated: they return their whole result in one call and always have a null cursor.
Responses are compressed with Brotli or gzip when the request includes Accept-Encoding: br or Accept-Encoding: gzip. Pages are large and highly repetitive JSON, and compression reduces transfer size by roughly five times. Always send Accept-Encoding.
Two optional filters narrow results. Both accept a repeated parameter or a comma-separated value, and the two forms can be mixed:
?question=OneThingChange&question=CommunityFreeText
?question=OneThingChange,CommunityFreeText
accommodation restricts which responses are in scope, by hall GUID. It affects every endpoint identically, including pagination and the counts in /summary. Hall GUIDs are found in the answerId of AccommodationName rows.
question restricts which answers are returned:
| Endpoint | Effect |
|---|---|
/responses |
Only rows with a matching questionId |
/responses/wide |
Only matching keys, plus responseid |
/questions |
Only matching questions |
/media, /sentiment |
Only items whose questionId matches |
/summary |
Counts reflect the filtered answer set |
The pseudo-questions are ordinary questions for this purpose: to receive submitdate or AccommodationName alongside a filtered set, include them in the list.
Matching is exact and case-insensitive. Unknown ids match nothing and are not an error. A question filter does not change which responses are in scope, so a response with no matching answers still occupies a place in a page but contributes no rows; /summary reports this as the difference between responsesInScope and responsesReturned.
GET /v1/questionsThe questions that have at least one answer within the window for your organisation, in the default language, ordered by questionId. One entry per question. Not paginated.
| Field | Type | Description |
|---|---|---|
questionId |
string | Identifier used throughout the other datasets |
lang |
string | Always default |
title |
string | Question text as shown to respondents |
The pseudo-questions are not listed here because they are not survey questions, but they do appear in the answer datasets.
GET /v1/responsesLong (entity-attribute-value) form. One row per response per answered question. Paginated by response.
| Field | Type | Description |
|---|---|---|
responseId |
integer | The response |
questionId |
string | The question, or pseudo-question |
answerId |
string or null | Identifier of the chosen option for choice questions; the hall GUID on AccommodationName and AccommodationCity rows; null for free text and other pseudo-questions |
lang |
string or null | default for choice answers, otherwise null |
value |
string or null | The answer as text. Choice answers carry the option label; free text carries the text; submitdate carries yyyy-MM-dd HH:mm:ss UTC |
All values are strings; apply your own typing downstream. A question that permits several selections may produce more than one row for the same responseId and questionId.
GET /v1/responses/wideWide form. One JSON object per response. Paginated by response, with exactly the same page boundaries as /responses for the same parameters.
Each object has a responseid key (integer) first, then one key per question in case-insensitive alphabetical order. Keys are the questionId strings verbatim, including dots. Values are strings, except submitdate, which is an ISO 8601 UTC timestamp. A question the response did not answer is present with a null value. If a question produced several values for one response they are joined with ; .
The set of keys is derived from the answers on that page, so it can differ between pages: a question nobody on a page answered is absent from that page. Consumers that need a fixed column set should take it from /questions plus the pseudo-questions, or pass an explicit question list.
GET /v1/mediaAttachments supplied with free-text answers. Paginated by response.
| Field | Type | Description |
|---|---|---|
responseId |
integer | The response |
questionId |
string | The question the media was attached to |
mediaType |
string or null | MIME type, for example audio/webm, video/mp4, image/jpeg |
mediaUrl |
string | URL of the media file |
transcription |
string or null | Automatic transcription of audio or video, where available |
Media URLs point at Index-hosted storage and are readable without further credentials at the time of writing; do not assume they are permanent.
GET /v1/sentimentThemes detected in free-text answers, with sentiment. Paginated by response.
| Field | Type | Description |
|---|---|---|
responseId |
integer | The response |
questionId |
string | The free-text question |
theme |
string or null | Detected theme, for example Maintenance and repairs; null when no theme was assigned |
sentiment |
number or null | Sentiment score for that theme, from -1 (most negative) to 1 (most positive); 0 is neutral |
confidence |
number or null | Confidence in the theme assignment, 0 to 1 |
A single answer can carry several themes and therefore several rows. Rows for the same response and question are ordered by descending sentiment.
GET /v1/summaryDiagnostic. Runs the same scope and filters as a data request and returns one object of counts instead of the data. Use it to size a job, to reconcile a completed extraction, or to check that filters do what you expect.
| Field | Type | Description |
|---|---|---|
responsesInScope |
integer | Responses selected by the window, wave and accommodation filter. This is what pagination counts |
responsesReturned |
integer | Distinct responseId values that /responses and /responses/wide would emit. Equal to responsesInScope unless a question filter excludes every answer of some responses |
answerRows |
integer | Total rows /responses would return across all pages |
questions |
integer | Distinct question ids in the answer set, including pseudo-questions |
accommodations |
integer | Distinct halls among the responses in scope |
firstSubmitted, lastSubmitted |
timestamp or null | Submit-date range of the responses in scope |
mediaRows |
integer | Total rows /media would return |
sentimentRows |
integer | Total rows /sentiment would return |
pageSize |
integer | The page size that would apply (your pageSize, defaulted and clamped) |
pages |
integer | Number of pages a paginated dataset would take at that size |
Errors use the application/problem+json format (RFC 9457).
| Status | Meaning | Body |
|---|---|---|
400 |
Invalid request | title, status, and for validation failures an errors object keyed by parameter name with an array of messages. Binding failures (missing from, unparseable date) carry the reason in detail |
401 |
Missing or incorrect credentials | Status and title only. WWW-Authenticate: Basic header |
404 |
Unknown path | |
429 |
Rate limit exceeded | detail states the wait; Retry-After header in seconds |
500 |
Server error | Status, title and a traceId. Quote the traceId when reporting the problem |
Example validation error:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "to": ["Date range cannot exceed 90 days."] },
"traceId": "00-8997b680aca486475dc2c4a9bcad815b-3acca9299daa428b-00"
}
/responses is roughly 20 MB uncompressed, about 4 MB compressed, and takes a few seconds./questions and /summary process the whole window and take a few seconds for a full wave./v1/summary for the window and filters to learn responsesInScope and pages./v1/questions once for the window to get titles for the ids you will see.meta.nextCursor after each page so an interrupted job can resume. Stop when nextCursor is null.responseId values received should equal responsesReturned, and the total row count should equal the corresponding *Rows figure from the summary.submitdate seen and use it as the next from. Because responses are keyed by responseId, re-pulling an overlapping window is safe if you upsert on responseId (and questionId for long rows).BASE=https://api.index.gslglobal.com
AUTH="clientId:clientSecret"
# size the job
curl -s --compressed -u "$AUTH" \
"$BASE/v1/summary?from=2026-02-01&to=2026-03-30&pageSize=1000"
# first page of long-form answers, two questions plus the submit date
curl -s --compressed -u "$AUTH" \
"$BASE/v1/responses?from=2026-02-01&to=2026-03-30&pageSize=1000&question=OneThingChange,CommunityFreeText,submitdate"
# next page: either follow meta.nextLink verbatim, or pass the cursor
curl -s --compressed -u "$AUTH" \
"$BASE/v1/responses?from=2026-02-01&to=2026-03-30&pageSize=1000&question=OneThingChange,CommunityFreeText,submitdate&cursor=pVflEQ%3D%3D"