Index Data API — Client Specification

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.

1. Overview

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

2. Authentication

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.

3. Data model

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

3.1 Identifiers are permanent; names and city are not

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.

4. Common request parameters

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:

4.1 Timestamps

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.

4.2 Window boundaries

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.

5. Response envelope

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"
  }
}

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.

5.1 Pagination

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:

/questions and /summary are not paginated: they return their whole result in one call and always have a null cursor.

5.2 Compression

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.

6. Filtering

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.

7. Endpoints

7.1 GET /v1/questions

The 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.

7.2 GET /v1/responses

Long (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.

7.3 GET /v1/responses/wide

Wide 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.

7.4 GET /v1/media

Attachments 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.

7.5 GET /v1/sentiment

Themes 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.

7.6 GET /v1/summary

Diagnostic. 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

8. Errors

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"
}

10. Performance expectations

  1. Choose the 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.
  2. Call /v1/summary for the window to learn responsesInScope and pages, and record meta.to.
  3. Pull /v1/questions once for the window to get titles for the ids you will see.
  4. Page through each dataset you need in full, without filters, storing meta.nextCursor after each page so an interrupted job can resume. Stop when nextCursor is null.
  5. Reconcile: the number of distinct responseId values received should equal responsesReturned, and the total row count should equal the corresponding *Rows figure from the summary.
  6. Store 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.
  7. Re-pulling an overlapping window is nevertheless safe, because responses are keyed by responseId: upsert on responseId (and questionId for long rows) and the overlap is idempotent.
  8. Key your accommodation and institution reference data on AccommodationId and InstitutionId, and overwrite the stored name and city from each pull. See section 3.1.

12. Example

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"