Anleitungen

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.

Die Entwickler-Dokumentation gibt es nur auf Englisch, wie die API selbst.

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.

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.

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.

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 shows how to pace a large first run.