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. It is built for bulk extraction: pull the datasets in full, then analyse them in your own tools.
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). An accommodation is the property the respondent lives in, identified by a GUID. An institution is the organisation the respondent belongs to, identified by an integer.
Every response also carries six pseudo-questions that appear alongside real questions in the answer data:
| questionId | Value | answerId |
|---|---|---|
submitdate |
When the response was submitted, yyyy-MM-ddTHH:mm:ssZ (see section 4.1) |
null |
AccommodationId |
The accommodation's GUID | The same GUID |
AccommodationName |
Name of the accommodation | The accommodation's GUID |
AccommodationCity |
City of the accommodation | The accommodation's GUID |
InstitutionId |
The institution's integer id, as a string | The same id |
InstitutionName |
Name of the institution the respondent belongs to | The institution's id |
responseId, AccommodationId and InstitutionId never change. AccommodationName, AccommodationCity and InstitutionName are read from the current Index record at the time of each request, so they can change between calls: an accommodation can be renamed, a city corrected, an institution retitled. Re-pulling an old window returns the same ids with possibly different text.
Build any mapping to your own systems on AccommodationId and InstitutionId, and treat the names and city as display labels to refresh on each pull. Do not join on names. AccommodationId is also the value the accommodation filter takes.
InstitutionId is null and InstitutionName is Unknown in the rare case that a response's institution reference does not resolve.
A response is in scope for your organisation when it was submitted from one of your accommodations during a period in which that accommodation was part of your programme. The from and to window is the only time selection you make; the API works out programme membership for each response itself.
Every /v1 endpoint accepts the same query parameters. Names are case-insensitive.
| Parameter | Required | Format | Description |
|---|---|---|---|
from |
Yes | yyyy-MM-ddTHH:mm:ssZ |
Inclusive start of the window: submitdate >= from |
to |
No | yyyy-MM-ddTHH:mm:ssZ |
Exclusive end of the window: submitdate < to. Defaults to the time of the request, to the second, and is echoed in meta.to |
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 accommodations, by AccommodationId. See section 6 |
Constraints, all enforced with a 400 response naming the parameter:
from and to must be in the form given in section 4.1. Any other form, including a date on its own, is rejected.to must not be before from. Equal values are an empty window, not an error.to minus from may be at most 90 days. Cover a longer period with consecutive windows (section 4.2).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 timestamp the API accepts or returns is written exactly one way:
yyyy-MM-ddTHH:mm:ssZ for example 2026-02-01T00:00:00Z
That is UTC, whole seconds, with the literal Z. It applies to from and to, to meta.from, meta.to and meta.generatedAt, to every submitdate value, and to firstSubmitted and lastSubmitted in /summary. A date on its own, a local time, an offset such as +00:00, or fractional seconds are all rejected with a 400 that states the required form. There is therefore never a question of which time zone a value is in or which instant a date means: a value read from any response can be sent straight back as from or to.
The window is half-open: a response is included when from <= submitdate < to. A response submitted at exactly 2026-03-01T00:00:00Z is in the window whose from is that instant and not in the window whose to is that instant.
Consecutive batches therefore share a boundary with no gap and no overlap: use the to of one batch as the from of the next.
from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z
from=2026-02-01T00:00:00Z&to=2026-03-01T00:00:00Z
from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z
When you omit to, the API uses the time of the request, to the second, and reports it in meta.to. Store that value and use it as the next from. Responses are stored with sub-second precision; the whole-second window still places each one on exactly one side of any boundary, so the guarantee holds.
Every successful call returns 200 with this shape:
{
"data": [ ... ],
"meta": {
"from": "2026-02-01T00:00:00Z",
"to": "2026-03-01T00:00:00Z",
"question": null,
"accommodation": null,
"count": 1000,
"nextCursor": "pVflEQ==",
"nextLink": "https://.../v1/responses?from=2026-02-01T00%3A00%3A00Z&to=2026-03-01T00%3A00%3A00Z&pageSize=1000&cursor=pVflEQ%3D%3D",
"generatedAt": "2026-09-04T10:22:19Z"
}
}
data is always an array, even for /summary which contains one element.meta.from and meta.to echo the window as applied, in the form of section 4.1. to is filled in with the request time when you omitted it; it is the value to use as the next batch's from. meta.question and meta.accommodation echo the list filters and 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. Timestamps are always yyyy-MM-ddTHH:mm:ssZ (section 4.1). Colons in query-string values may be sent literally or percent-encoded; nextLink uses the encoded form.
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 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.
The API is a bulk extraction interface. The intended use is to pull each dataset for a window in full, load it into your own warehouse or analysis tools, and do all selection and analysis there. The two optional filters below exist for operational reasons: checking one accommodation during onboarding, re-pulling one question after a correction, or keeping a test payload small. Do not build reporting on filtered requests; a filtered pull is not a substitute for holding the full dataset.
Both filters accept a repeated parameter or a comma-separated value, and the two forms can be mixed:
?question=OneThingChange&question=CommunityFreeText
?question=OneThingChange,CommunityFreeText
accommodation narrows which responses are in scope, by AccommodationId. Every endpoint sees the same narrowed set, including pagination and the counts in /summary.
question narrows the answers returned to the listed question ids, in every dataset. The pseudo-questions count as questions here, so include submitdate or AccommodationId in the list if you need them alongside a filtered set. A question filter does not change which responses are in scope: a response with none of the listed answers still occupies its place in a page but contributes no rows, and /summary reports the difference as responsesInScope minus responsesReturned.
Matching is exact and case-insensitive. Unknown ids match nothing and are not an error.
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 accommodation GUID on AccommodationId, AccommodationName and AccommodationCity rows; the institution id on InstitutionId and InstitutionName rows; null for free text and submitdate |
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-ddTHH:mm:ssZ |
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 a timestamp in the form of section 4.1 (it is still a JSON string, but typed as a timestamp in the OpenAPI document). AccommodationId and InstitutionId are strings here too; the wide form has no answerId, so these two keys are where the permanent ids live. 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 and the 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 accommodations among the responses in scope |
firstSubmitted, lastSubmitted |
timestamp or null | Earliest and latest submitdate among the responses in scope, yyyy-MM-ddTHH:mm:ssZ |
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 90-day window.from and to in the form of section 4.1, at most 90 days apart. For a catch-up job, from is the previous run's meta.to and to is omitted./v1/summary for the window to learn responsesInScope and pages, and record meta.to./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.meta.to as the next run's from. Because from is inclusive and to exclusive (section 4.2), consecutive runs neither miss nor repeat a response at the boundary. Do not derive the next from from the last submitdate you saw: that re-pulls every response in that second and, if the page order and submit order differ, can skip responses.responseId: upsert on responseId (and questionId for long rows) and the overlap is idempotent.AccommodationId and InstitutionId, and overwrite the stored name and city from each pull. See section 3.1.BASE=https://api.index.gslglobal.com
AUTH="clientId:clientSecret"
# size the job: February 2026, half-open, so 1 March 00:00:00Z itself is not included
curl -s --compressed -u "$AUTH" \
"$BASE/v1/summary?from=2026-02-01T00:00:00Z&to=2026-03-01T00:00:00Z&pageSize=1000"
# first page of long-form answers, every question
curl -s --compressed -u "$AUTH" \
"$BASE/v1/responses?from=2026-02-01T00:00:00Z&to=2026-03-01T00:00:00Z&pageSize=1000"
# next page: either follow meta.nextLink verbatim, or pass the cursor
curl -s --compressed -u "$AUTH" \
"$BASE/v1/responses?from=2026-02-01T00:00:00Z&to=2026-03-01T00:00:00Z&pageSize=1000&cursor=pVflEQ%3D%3D"
# the next batch starts exactly where this one ended
curl -s --compressed -u "$AUTH" \
"$BASE/v1/responses?from=2026-03-01T00:00:00Z&to=2026-04-01T00:00:00Z&pageSize=1000"