# Syncing results

Copy Simple Feedback responses into your own database or BI tool, fetch only new ones on each run, and turn answers back into question texts.

This guide keeps a copy of every response in your own system, for example a data warehouse, a CRM or a
spreadsheet. It needs a key with the scopes `surveys:read` and `responses:read`. The shell variables `SF_API` and
`SF_KEY` are set up as in the [Quickstart](https://simplefeedback.de/developer/docs/quickstart.md).

## Pick what to copy

| You need                           | Read                              | Scope            |
| ---------------------------------- | --------------------------------- | ---------------- |
| Every single answer                | `GET /v1/surveys/{id}/responses`  | `responses:read` |
| Totals, averages and distributions | `GET /v1/surveys/{id}/statistics` | `responses:read` |
| The AI summary of the comments     | `GET /v1/surveys/{id}/insights`   | `insights:read`  |

If a dashboard only shows numbers, read the statistics and skip the rest of this guide: one call per survey returns
what you would otherwise compute from thousands of responses. Copy the responses when you want to join them with
your own data, such as bookings or orders.

## List the surveys

```bash
curl "$SF_API/v1/surveys?limit=100" -H "Authorization: Bearer $SF_KEY"
```

Sync each survey in the list, including drafts that already have responses. Narrow the list to one venue with
`venueId`, and page with `offset` past 100 surveys.

## Fetch only new responses

Responses come newest first, at most 100 per page, and there is no filter by date. To fetch only what you don't
have yet, page from the start and stop at the first response you already stored:

```typescript
interface ResponseRecord {
  id: string;
  submittedAt: string;
  answers: Record<string, unknown>[];
  metadata: Record<string, unknown> | null;
}

interface ResponsePage {
  data: ResponseRecord[];
  pagination: { total: number; limit: number; offset: number };
}

const API = process.env.SF_API;
const KEY = process.env.SF_KEY;

async function get<T>(path: string): Promise<T> {
  for (;;) {
    const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${KEY}` } });
    if (res.status === 429) {
      await new Promise((resolve) => setTimeout(resolve, Number(res.headers.get('Retry-After')) * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    return res.json() as Promise<T>;
  }
}

// Returns the responses not stored yet, newest first. `isStored(id)` looks the id up in your database.
async function fetchNewResponses(surveyId: string, isStored: (id: string) => boolean): Promise<ResponseRecord[]> {
  const fresh = new Map<string, ResponseRecord>();
  for (let offset = 0; ; offset += 100) {
    const page = await get<ResponsePage>(`/v1/surveys/${surveyId}/responses?limit=100&offset=${offset}`);
    for (const response of page.data) {
      if (isStored(response.id)) return [...fresh.values()];
      fresh.set(response.id, response);
    }
    if (offset + page.data.length >= page.pagination.total) return [...fresh.values()];
  }
}
```

The first run reads everything. Later runs need one request for a survey with nothing new, and one more for every\n100 new responses. Store each response under its `id` and
write it with an upsert:

- A respondent who answers while you page pushes older responses one place further back, so the next page can repeat a
  response you just read. The `Map` drops the repeat, and the upsert makes a repeat across runs harmless too.
- A response never changes after it is submitted, so a stored one never needs to be read again.

> **Note:** Responses deleted in Simple Feedback, by a retention period or by a deletion in the dashboard, are not
> reported to you. If your copy has to follow such deletions, compare the number you hold with
> `pagination.total` from time to time and read the survey again in full when they differ.

## Turn answers into readable rows

An answer names its question by `questionId` and, for a choice question, the picked options by their ids:

```json
{
  "id": "cmv41mjyv0003ee7d17meblic",
  "submittedAt": "2026-10-11T16:34:19.159Z",
  "answers": [
    { "questionId": "q_794c4881", "answeredAt": "2026-10-11T16:34:03Z", "rating": 5 },
    { "questionId": "q_94557f22", "answeredAt": "2026-10-11T16:34:05Z", "selectedOptions": ["opt_81f4ca19"] },
    { "questionId": "q_7d5c1ebb", "answeredAt": "2026-10-11T16:34:09Z", "score": 10 },
    { "questionId": "q_12590f79", "answeredAt": "2026-10-11T16:34:15Z", "text": "More vegan options" }
  ],
  "metadata": { "elapsedMs": 42003 }
}
```

The texts are in the survey from `GET /v1/surveys/{id}`, under `modules[].questions[]`. Each question has an
`id`, a `type` and its `question` text per language; a choice question also has `options`, each with an `id` and a
`text`:

```typescript
interface Question {
  id: string;
  type: string;
  question: Record<string, string>;
  options?: { id: string; text: Record<string, string> }[];
}

function questionsById(survey: { modules: { questions?: Question[] }[] }): Map<string, Question> {
  const questions = new Map<string, Question>();
  for (const module of survey.modules) {
    for (const question of module.questions ?? []) questions.set(question.id, question);
  }
  return questions;
}
```

The value of an answer sits in a field that depends on the question's `type`:

| `type`      | Answer fields                                                               |
| ----------- | --------------------------------------------------------------------------- |
| `rating`    | `rating`: the chosen level, from 1                                          |
| `choice`    | `selectedOptions`: the ids of the picked options                            |
| `nps`       | `score`: 0 to 10; `followUpText` when the respondent answered the follow-up |
| `open_text` | `text`                                                                      |
| `date`      | `dates`: one ISO 8601 string, or two for a range                            |
| `time`      | `time`                                                                      |
| `datetime`  | `datetime`                                                                  |

A question the respondent skipped has no answer at all, and a `section` never has one.

Read the survey again whenever its `updatedAt` changes. Someone may have reworded a question or deleted it: old
answers keep their `questionId`, so keep the texts you resolved them with, or show a deleted question's answers
under its id.

`metadata` holds at most `source`, `elapsedMs` (how long the respondent took, in milliseconds) and `v1UniqueId` for
responses imported from the old Simple Feedback.

## The whole organization at a glance

Two routes answer for every venue at once instead of one survey at a time. `GET /v1/report?periodDays=30` returns
responses, average rating and QR scans in total, per day and per venue, each next to the period before.
`GET /v1/responses?limit=20` returns the newest responses over every survey, each with its rating and first comment.
Both need `responses:read`; the report also needs a plan with cross-venue reporting.

```bash
curl "$SF_API/v1/report?periodDays=7" \
  -H "Authorization: Bearer $SF_KEY"
```

## How often to sync

There are no webhooks yet, so sync on a schedule. Every few minutes is plenty for a dashboard, and once a night is
enough for a report. A key may send 60 requests per minute to each route; the function above already waits out a
`429`, and [Rate limits](https://simplefeedback.de/developer/docs/rate-limits.md) shows how to pace a large first run.
