---
title: Migrating from v1 to v2
description: >-
  Data Export API v1 will be turned off on September 15, 2026. This guide maps
  every v1 endpoint, parameter and response field to its v2 equivalent, so you
  can update your integration without guessing.
source_url:
  html: 'https://developers.survicate.com/data-export/migration-v1-v2/'
  md: 'https://developers.survicate.com/data-export/migration-v1-v2.md'
---
# Migrating from v1 to v2

Data Export API v1 will be turned off on **September 15, 2026**. This guide maps every v1 endpoint, parameter and response field to its v2 equivalent, so you can update your integration without guessing.

Authentication is unchanged: pass your API key in the `Authorization` header as `Basic {{apiKey}}`. Only the base URL and the request/response shapes change.

```
v1 base URL: https://data-api.survicate.com/v1
v2 base URL: https://data-api.survicate.com/v2
```

---

## Endpoint map

| v1 | v2 |
|---|---|
| `GET /surveys` | `GET /surveys` — now paginated, results wrapped in `data` |
| `GET /surveys/{survey_id}` | Split in two: `GET /surveys/{survey_id}` for survey metadata, `GET /surveys/{survey_id}/questions` for the question list |
| `GET /surveys/{survey_id}/visitors` | `GET /surveys/{survey_id}/responses` |
| `GET /visitors/{visitor_uuid}` | Split in two: `GET /respondents/{respondent_uuid}/responses` for their responses, `GET /respondents/{respondent_uuid}/attributes` for their attributes |
| — | New in v2: `GET /surveys/{survey_id}/responses/{response_uuid}` fetches a single response |

Two naming changes run through the whole API:

- **"Visitor" became "respondent".** Both versions identify a person by the same UUID — v1 called it `visitor_uuid`, v2 calls it `respondent_uuid` (or `respondent.uuid` inside a response). Numeric visitor IDs don't exist in v2; v1 itself stopped returning them in January 2025.
- **Every list endpoint wraps results.** v1 returned a bare JSON array. v2 returns an object with `pagination_data` and `data`:

```json
{
  "pagination_data": {
    "has_more": true,
    "next_url": "/surveys/69f3dcf0d3220de7/responses?start=2024-08-29T09:12:44.128000Z&end=2024-08-26T00:00:00.000000Z"
  },
  "data": [...]
}
```

---

## Exporting responses: `/visitors` → `/responses`

The most common v1 call:

```shell
curl -H 'Authorization: Basic {{apiKey}}' \
  'https://data-api.survicate.com/v1/surveys/{survey_id}/visitors?since=2024-08-26T00:00:00Z&until=2024-08-31T23:59:59Z&page=1'
```

Its v2 equivalent:

```shell
curl -H 'Authorization: Basic {{apiKey}}' \
  'https://data-api.survicate.com/v2/surveys/{survey_id}/responses?start=2024-08-31T23:59:59.000000Z&end=2024-08-26T00:00:00.000000Z'
```

### Date range: `since`/`until` → `start`/`end`

> **Note:**
> The two timestamps swap places compared to v1. v2 orders responses from **latest to oldest**, so `start` is the **newer** bound (where the page starts) and `end` is the **older** bound. Passing your old `since` value as `start` returns an empty result.

| | v1 | v2 |
|---|---|---|
| Older bound | `since` | `end` |
| Newer bound | `until` (only honored together with `since`) | `start` |
| Inclusivity | both inclusive | both inclusive |
| Format | ISO 8601 (`2024-08-26T00:00:00Z`) | ISO 8601; seconds precision is accepted, timestamps in responses always carry microseconds (`2024-08-26T00:00:00.000000Z`) |

### Ordering

v1 returned responses oldest-to-latest by default, with an optional `order=desc` parameter. v2 always returns latest-to-oldest; there is no `order` parameter.

### Pagination: `page` → `next_url`

v1 pages were fixed at 1,000 responses each. In v2 there are no page numbers — instead of requesting `page=2`, follow the link the API builds for you:

1. Make the first request, optionally with `items_per_page` (1–100; omitted = default of 20).
2. Read `pagination_data` in the response. While `has_more` is `true`, request `next_url` — it is **always a path relative to the base URL** `https://data-api.survicate.com/v2`, so prepend the base URL and call it as-is.
3. Stop when `has_more` is `false`.

Under the hood `next_url` shifts the `start` timestamp back to where the previous page ended, keeping your `end` and filters intact — you never compute page boundaries yourself. A full walkthrough with example requests is in [Setup → Pagination](/data-export/setup#pagination).

### Filtering

v1 had no filters beyond the date range. v2 accepts an optional `filters` array (operating system, device type, platform, survey language, response URL, custom attributes, question answers) and an `attributes[]` parameter naming the attributes to include in each response — see the note in [Response object changes](#response-object-changes) below. See [Response](/data-export/response) for the schemas.

---

## Response object changes

v1 returned one flat object per visitor, mixing identifiers, profile fields and answers. v2 returns a response object with a fixed set of native fields, a nested `respondent` object, and attribute arrays.

| v1 field | v2 equivalent |
|---|---|
| `response_uuid` | `uuid` |
| `visitor_uuid` | `respondent.uuid` |
| `page_url` | `url` |
| `language` | `language` (unchanged) |
| `answers` | `answers` (same name, new per-type structure, see below) |
| `custom_attributes` (object of key/value) | `respondent.attributes` — an array of `{ "name": ..., "value": ... }` pairs (values are always strings), returned only for the names you list in the `attributes[]` query parameter |
| Profile fields at top level (`first_name`, `last_name`, `email`, `organization`, `phone`, `country`, and the rest) | Removed as dedicated fields. If you pass them to Survicate, they come back in `respondent.attributes` like any other attribute — v2 makes no distinction between profile and custom attributes |
| `identity`, `first_seen_date`, `first_response_date` | Not returned in v2 |
| — | New in v2: `collected_at` (response timestamp), `device_type`, `operating_system`, `platform` (a device platform identifier such as `MacIntel` or `Win32`), and a top-level `attributes` array holding attributes recorded for that specific response |

Note there are **two** attribute arrays in a v2 response: `respondent.attributes` (attributes of the person, the v1 `custom_attributes`) and the top-level `attributes` (attributes recorded with that one response).

> **Note:**
> v1 returned all of a visitor's `custom_attributes` with every response by default. On the v2 responses list, attributes are returned **only when you ask for them**: list the attribute names in the `attributes[]` query parameter (`?attributes[]=order_id&attributes[]=store`). Without it, both attribute arrays come back empty — if your import suddenly sees no attributes after migrating, this is why. (The single-response endpoint differs: it always returns the response-level `attributes` and applies the parameter only to `respondent.attributes`.)

---

## Fetching one person's data: `/visitors/{uuid}` → `/respondents/{uuid}`

The v1 `GET /visitors/{visitor_uuid}` endpoint returned one object combining the visitor's profile fields, `custom_attributes`, `tags`, `comment`, and a `reports` array with their responses across surveys. In v2 that splits into two paginated endpoints:

- `GET /respondents/{respondent_uuid}/attributes` returns what v1 kept in the profile fields and `custom_attributes` — as `{ "id": ..., "name": ..., "value": ... }` entries. `tags` and `comment` are not returned in v2.
- `GET /respondents/{respondent_uuid}/responses` returns what v1 kept in `reports`. Each `data` entry pairs a `survey` object with a `response` object. Response objects on this endpoint always carry empty attribute arrays — the respondent's attributes come only from the attributes endpoint above:

| v1 (`reports[]` entry) | v2 (`data[]` entry) |
|---|---|
| `survey_name` | `survey.name` (plus `survey.id` and `survey.type`, which v1 did not return) |
| `platform` (`desktop` or `mobile`, detected from the user agent) | `response.device_type` |
| `date` | `response.collected_at` |
| `response_uuid` | `response.uuid` |
| `answers` | `response.answers` (new per-type structure, see [Answer format changes](#answer-format-changes)) |

---

## Answer format changes

Each entry in the v1 `answers` array had the same wrapper regardless of question type:

```json
{
  "survey_point": {
    "id": 2542,
    "type": "question",
    "pretty_type": "Single choice",
    "answer_type": "single"
  },
  "question": "What prevents you from signing up?",
  "content": "I can't find pricing details"
}
```

The `content` handling varied by type, though: `multiple` joined the selections into one string with `" | "`, `matrix` combined a joined string with a nested `answers` array, `ranking` had no `content` at all (only a nested `answers` array), and a contact form produced one entry per form field. v2 replaces all of that with one explicit payload per question type:

```json
{
  "question_id": 2542,
  "question_type": "single",
  "answer": {
    "id": 75834,
    "content": "I can't find pricing details",
    "comment": null,
    "translated_comment": null,
    "disclaimer_accepted": null,
    "insight": null
  }
}
```

What moved where:

| v1 | v2 |
|---|---|
| `survey_point.id` | `question_id` |
| `survey_point.answer_type` | `question_type` — renamed, but the values are the same strings as in v1 |
| `survey_point.type`, `survey_point.pretty_type` | Removed |
| `question` (question text repeated in every answer) | Not repeated per answer. Fetch the survey's questions once via `GET /surveys/{survey_id}/questions` and join by `question_id` |
| `content` | `answer` / `answers` / `fields`, structured per question type (table below) |

### Answer payload by question type

| `question_type` | v2 payload |
|---|---|
| `single`, `dropdown_list` | `answer`: `{ id, content, comment }` |
| `multiple` | `answers`: array of `{ id, content, comment }` |
| `rating`, `smiley_scale`, `numerical_scale`, `csat` | `answer`: `{ id, content, rating }` — `rating` is the numeric value |
| `text`, `date` | `answer`: the entered text as a string |
| `nps` | `answer`: `{ content, tag }` (see below) |
| `matrix` | `answers`: array of `{ id, content, score }` — one entry per matrix row, `score` is the selected column label |
| `ranking` | `answers`: array of `{ id, content, rank }` — `rank` is the position given by the respondent |
| `form` | `fields`: array of `{ type, content }` — one entry per contact-form field |
| `empty`, `button_close`, `button_link`, `button_next`, `redirect_timeout`, `social_cta` | `action_performed`: `true` — welcome/thank-you screen interactions. The entry only appears when the action happened |

Payloads also carry auxiliary fields serialized as `null` when they don't apply — `comment`, `translated_comment` and `disclaimer_accepted` on most types, plus `insight` on choice answers and `translated_answer` / `ai_followups` / `translated_ai_followups` on `text` answers. Parse the fields you need and ignore unknown keys rather than validating against a closed set.

### NPS

`question_type` is `nps`. `answer.content` is the 0–10 score as a string, and `answer.tag` is the NPS group derived from it:

| `tag` | Score |
|---|---|
| `detractor` | 0–6 |
| `passive` | 7–8 |
| `promoter` | 9–10 |

```json
{
  "question_id": 372624,
  "question_type": "nps",
  "answer": {
    "content": "9",
    "tag": "promoter",
    "comment": null,
    "translated_comment": null,
    "disclaimer_accepted": null
  }
}
```

### `dropdown_list` and `ranking`

Neither is a new question type — both existed in v1 under the same `answer_type` strings. What changed is the payload shape: `dropdown_list` answers arrive like single-choice answers (one `answer` object), and `ranking` answers arrive as an `answers` array ordered by the respondent, each with its `rank`.

### Skipped questions

This behavior **changed** between versions. v1 emitted an entry for every question of the survey in every response — a skipped question still appeared, just with `content` missing or empty. In v2 a skipped question does not appear in the `answers` array at all: no placeholder entries, no `null` answers. If your import expects every question to be present, join the response against the survey's question list and treat missing `question_id`s as skipped.

---

## Rate limits

| Limit | v2 |
|---|---|
| Concurrent requests | 5 |
| Requests per minute per workspace | 1,000 |

Exceeding either returns `429 Too Many Requests`. Because pagination follows `next_url`, pages must be fetched sequentially — you learn the next URL only from the previous response.

---

## Migration checklist

1. Switch the base URL to `https://data-api.survicate.com/v2` — the API key and `Authorization` header stay the same.
2. Replace `/surveys/{survey_id}/visitors` with `/surveys/{survey_id}/responses`.
3. Swap the date parameters: your old `until` value becomes `start`, your old `since` value becomes `end`.
4. Replace `page`-based pagination with the `has_more` / `next_url` loop, prepending the base URL to `next_url`.
5. Read list results from the `data` field instead of the top-level array.
6. Rename fields per the tables above (`response_uuid` → `uuid`, `visitor_uuid` → `respondent.uuid`, `page_url` → `url`, `answer_type` → `question_type`).
7. Read respondent profile fields and custom attributes from `respondent.attributes` instead of top-level fields — and request them explicitly via the `attributes[]` query parameter, or they won't be returned at all.
8. Parse answers per question type instead of the v1 `content` conventions.
9. Expect skipped questions to be absent from `answers` — v1 emitted placeholder entries for them, v2 does not.
10. If you rely on ascending order, sort by `collected_at` on your side — v2 always returns latest first.
