rifts.to
← Alle Beiträge

Umfrageantworten mit der rifts.to-API ins CRM synchronisieren

2026-11-02

Was dieses Skript macht

In den früheren Teilen dieser Serie kamen die Leads vom Messestand nach der Veranstaltung ins CRM, zuerst per CSV-Import und dann über Claude, beides Teil von Pro. Dieser Teil erledigt das während der Veranstaltung: Ein kleines Skript liest die Umfrage alle paar Minuten und schickt jeden neuen „Call me“-Lead (also jede Bitte um einen Anruf) an dein CRM. So kann jemand im Büro eine Person anrufen, die noch im Gebäude ist.

Wenn du ein Terminal öffnen, eine Umgebungsvariable setzen und eine Webhook-URL einfügen kannst, kannst du es ausführen. Das Skript ist reines JavaScript, ohne Pakete, die du installieren musst.

Bevor du anfängst

  • Ein Pro-Konto. Die rifts.to-API gehört zu Pro, und das Abo wird bei jedem Aufruf geprüft.
  • Ein API-Token. Erstell eins in deinen Kontoeinstellungen und gib ihm einen Namen, zum Beispiel „Lead-Sync Messestand“. Es wird nur einmal angezeigt, also kopier es direkt in eine Umgebungsvariable namens RIFTS_TOKEN, niemals in Browser-Code oder ein geteiltes Dokument. Ein Token handelt für dein ganzes Konto, nicht für eine einzelne Umfrage.
  • Ein Ziel für die Leads. Ein eingehender Webhook in Zapier oder Make, der einen Lead im CRM anlegt, oder die API deines CRMs. Die URL kommt in CRM_WEBHOOK_URL.
  • Node.js 18 oder neuer, wo fetch schon eingebaut ist.

Jede Anfrage schickt das Token in einem Header, Authorization: Bearer rifts_pat_.... Ein Token im Query-String wird mit 401 abgelehnt.

Die Umfrage-ID finden

Die Umfrage-ID ist der letzte Teil ihres öffentlichen Links. Bei https://rifts.to/en/s/fuzzy-sleepy-tornado ist die ID fuzzy-sleepy-tornado. Du kannst deine Umfragen auch auflisten:

curl https://rifts.to/api/v1/surveys \
  -H "Authorization: Bearer $RIFTS_TOKEN"

Leg die ID in eine Umgebungsvariable namens SURVEY_ID.

Oder die Lead-Umfrage aus dem Skript heraus erstellen

Wenn du die Umfrage noch nicht gebaut hast, legt POST /api/v1/surveys sie an. Die Fragen folgen Fragen zur Lead-Qualifizierung für deine Umfrage, und die Kontaktfragen nutzen die Antwortregeln aus Pro, damit sie nur für die Leute Pflicht sind, die um ein Follow-up gebeten haben.

curl -X POST https://rifts.to/api/v1/surveys \
  -H "Authorization: Bearer $RIFTS_TOKEN" \
  -H "Content-Type: application/json" \
  -d @lead-survey.json

Dabei enthält lead-survey.json:

{
  "title": "Booth follow-up",
  "questions": [
    { "type": "multiple_choice", "text": "Which best describes you?",
      "options": ["Evaluating tools for my team", "Using something similar", "Here for the talk"] },
    { "type": "multiple_choice", "text": "When are you looking to make a change?",
      "options": ["This quarter", "This year", "No plans yet"] },
    { "type": "multiple_choice", "text": "What should we do next?",
      "options": ["Email me the slides", "Call me", "Nothing for now"] },
    { "type": "free_text", "text": "Email address (only if you want the slides or a call)",
      "requirement": { "mode": "conditional", "when": { "op": "any",
        "conditions": [{ "questionIndex": 2, "values": ["Email me the slides", "Call me"] }] } } },
    { "type": "free_text", "text": "Phone number (only if you asked for a call)",
      "requirement": { "mode": "conditional", "when": { "op": "any",
        "conditions": [{ "questionIndex": 2, "values": ["Call me"] }] } } },
    { "type": "multiple_choice", "text": "Best time to call (only if you asked for a call)",
      "options": ["Morning", "Afternoon", "Either"],
      "requirement": { "mode": "conditional", "when": { "op": "any",
        "conditions": [{ "questionIndex": 2, "values": ["Call me"] }] } } }
  ]
}

Eine Bedingung verweist über ihre Position auf eine frühere Multiple-Choice-Frage, gezählt ab 0, und ihre Werte müssen exakt mit den Optionen dieser Frage übereinstimmen. Regeln verstecken nie eine Frage: Alle sehen alle sechs, und eine Regel entscheidet nur, ob eine Antwort Pflicht ist. Deshalb steht in den Beschriftungen, für wen jede Frage gedacht ist. Siehe Bedingte und optionale Fragen.

Die Antwort enthält die id der Umfrage, ihre öffentliche url für den QR-Code und eine admin_url. Halt den Admin-Link privat. Über ihn verfolgst du die Ergebnisse live, und der Lese-Endpunkt gibt ihn nie zurück.

Was das Abrufen einer Umfrage liefert

GET /api/v1/surveys/{id} liefert die Fragen und alle Antworten:

{
  "id": "fuzzy-sleepy-tornado",
  "title": "Booth follow-up",
  "status": "open",
  "expires_at": null,
  "questions": [
    { "type": "multiple_choice", "text": "Which best describes you?", "options": [...], "index": 0 },
    ...
  ],
  "response_count": 1,
  "responses": [
    { "id": "0a9c7f21-5b64-4a3e-8c11-9d0e7f6a5b43",
      "submitted_at": "2026-11-05T14:11:52.907Z",
      "answers": [ ... ] }
  ]
}

Antworten werden nach der Position der Frage gespeichert. Die Antwort einer Person auf eine Frage liest du mit answers[q.index], wobei q das Frage-Objekt ist. Eine Multiple-Choice-Antwort ist der Text der Option, eine Freitextantwort genau das, was die Person getippt hat. Eine übersprungene optionale Frage hat keinen Wert. status ist open, closed oder expired.

Das Skript erkennt jede Frage am Anfang ihres Textes, nicht an einer festen Position. Es funktioniert also auch noch, wenn später jemand eine Frage hinzufügt.

Das Skript

Speicher es als sync-leads.mjs. Pass den Fragetext in QUESTIONS und die Option CALL_ME an, wenn deine Umfrage sie anders formuliert.

// sync-leads.mjs: forward new "Call me" leads from a rifts.to survey to a CRM.
// Needs Node 18 or later. No packages to install.
// Run once:        node sync-leads.mjs
// Run until close: node sync-leads.mjs --watch
import { readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://rifts.to/api/v1";
const TOKEN = process.env.RIFTS_TOKEN;
const SURVEY_ID = process.env.SURVEY_ID;
const CRM_WEBHOOK_URL = process.env.CRM_WEBHOOK_URL;
const SENT_FILE = process.env.SENT_FILE || "sent-responses.json";
const WATCH_MINUTES = 5;

if (!TOKEN || !SURVEY_ID || !CRM_WEBHOOK_URL) {
  console.error("Set RIFTS_TOKEN, SURVEY_ID and CRM_WEBHOOK_URL first.");
  process.exit(1);
}

// The start of each question's text, as written in your survey.
// Matching ignores case, so "Phone number" also finds
// "Phone number (only if you asked for a call)".
const QUESTIONS = {
  nextStep: "What should we do next?",
  email: "Email address",
  phone: "Phone number",
  bestTime: "Best time to call",
};
// Must match the multiple choice option exactly.
const CALL_ME = "Call me";

async function getSurvey() {
  const res = await fetch(API + "/surveys/" + encodeURIComponent(SURVEY_ID), {
    headers: { Authorization: "Bearer " + TOKEN },
  });
  if (res.ok) return res.json();

  const body = await res.json().catch(() => ({}));
  if (res.status === 401) {
    throw new Error("401: rifts.to did not accept the token. Check RIFTS_TOKEN, or create a new one.");
  }
  if (res.status === 403 && body.code === "entitlement_required") {
    throw new Error("403: this account has no current Pro subscription, so the API is off.");
  }
  if (res.status === 404) {
    throw new Error("404: no survey " + SURVEY_ID + " on this token's account. Check SURVEY_ID.");
  }
  if (res.status === 429) {
    const wait = res.headers.get("Retry-After");
    throw new Error("429: over 600 calls this hour on this token. " +
      (wait ? "Try again in " + wait + " seconds." : "Try again later."));
  }
  throw new Error("rifts.to answered " + res.status + ": " + (body.error || "unknown error"));
}

function findQuestion(questions, start) {
  const wanted = start.toLowerCase();
  const found = questions.find((q) => q.text.toLowerCase().startsWith(wanted));
  if (!found) throw new Error('No question starting with "' + start + '" in this survey.');
  return found;
}

// Answers are stored by question position, so look them up by q.index.
function answerTo(response, question) {
  const value = response.answers[question.index];
  if (value === undefined || value === null) return "";
  return String(value).trim();
}

async function loadSent() {
  try {
    return new Set(JSON.parse(await readFile(SENT_FILE, "utf8")));
  } catch (err) {
    if (err.code === "ENOENT") return new Set();
    throw err;
  }
}

async function saveSent(sent) {
  await writeFile(SENT_FILE, JSON.stringify(Array.from(sent), null, 2));
}

// Sends one lead as JSON. CRM_WEBHOOK_URL can be an incoming webhook
// in Zapier or Make that creates the lead in your CRM, or your CRM's
// own API. If your CRM's API needs its own key, add that header here.
async function sendToCrm(lead) {
  const res = await fetch(CRM_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(lead),
  });
  if (!res.ok) throw new Error("The CRM webhook answered " + res.status + ".");
}

async function syncOnce() {
  const survey = await getSurvey();
  const q = {
    nextStep: findQuestion(survey.questions, QUESTIONS.nextStep),
    email: findQuestion(survey.questions, QUESTIONS.email),
    phone: findQuestion(survey.questions, QUESTIONS.phone),
    bestTime: findQuestion(survey.questions, QUESTIONS.bestTime),
  };
  const sent = await loadSent();
  let forwarded = 0;

  for (const response of survey.responses) {
    if (sent.has(response.id)) continue;
    if (answerTo(response, q.nextStep) !== CALL_ME) continue;

    await sendToCrm({
      source: "rifts.to",
      survey_id: survey.id,
      response_id: response.id,
      submitted_at: response.submitted_at,
      email: answerTo(response, q.email),
      phone: answerTo(response, q.phone),
      best_time: answerTo(response, q.bestTime),
    });
    // Saved after every lead, so a crash halfway never sends one twice.
    sent.add(response.id);
    await saveSent(sent);
    forwarded++;
  }

  // Counts only. Never print email addresses or phone numbers.
  console.log(new Date().toISOString() + " forwarded " + forwarded +
    " new lead(s); " + survey.response_count + " responses so far.");
  return survey.status;
}

async function main() {
  const watch = process.argv.includes("--watch");
  while (true) {
    const status = await syncOnce();
    if (status !== "open") {
      console.log("The survey is " + status + " and takes no new answers. Stopping.");
      return;
    }
    if (!watch) return;
    await sleep(WATCH_MINUTES * 60 * 1000);
  }
}

main().catch((err) => {
  console.error(err.message);
  process.exit(1);
});

Es liest die Umfrage, findet die vier Fragen, lädt die schon gesendeten Antwort-IDs aus sent-responses.json und leitet jede weitere „Call me“-Antwort als kleines JSON-Objekt weiter. Fehler beenden den Lauf mit einer verständlichen Meldung: 401 bei einem falschen oder widerrufenen Token, 403 entitlement_required, wenn Pro abgelaufen ist, 404 bei einer falschen Umfrage-ID oder einer aus einem anderen Konto (dieselbe Antwort für beides) und 429, wenn das Stundenlimit überschritten ist.

Während der Veranstaltung alle paar Minuten ausführen

Zwei Wege, das zu planen:

  • Auf einem Laptop vor Ort führst du node sync-leads.mjs --watch aus. Es synchronisiert alle 5 Minuten und hört von selbst auf, sobald die Umfrage geschlossen oder abgelaufen ist.
  • Auf einem Server nimmst du cron oder die geplanten Jobs deiner Plattform und führst es ohne --watch aus. Leg die drei Variablen in eine Datei, die nur du lesen kannst, nicht ins Skript:
*/5 * * * * cd /home/you/lead-sync && . ./.env && node sync-leads.mjs >> sync.log 2>&1

Die API erlaubt 600 Aufrufe pro Stunde und Token. Ein Lauf ist ein Aufruf, alle 5 Minuten sind also 12 Aufrufe pro Stunde, und selbst jede Minute sind es nur 60.

Warum sich das Skript Antwort-IDs merkt

Eine rifts.to-Antwort trägt keine Identität der antwortenden Person. Sie enthält eine zufällige ID, die Antworten und den Zeitpunkt des Eingangs, ohne Namen, Konto, IP-Adresse oder Cookie. Die Antwort-ID ist das Einzige, woran sich Doppelte zuverlässig erkennen lassen, also merkt sich das Skript jede, die es schickt, und überspringt sie beim nächsten Mal.

Das verhindert, dass dieselbe Antwort zweimal rausgeht. Es verhindert nicht, dass eine Person zweimal antwortet, nach deinem Vortrag und noch einmal am Stand: zwei Antworten, zwei IDs, zwei Leads. Gleich die E-Mail-Adressen im CRM oder in deinem Zapier- oder Make-Szenario ab, bevor jemand zum Hörer greift.

Die Zahl prüfen, ohne alle Antworten zu laden

Für ein schnelles „Wie viele sind es bisher?“ häng ?view=aggregate an:

curl "https://rifts.to/api/v1/surveys/fuzzy-sleepy-tornado?view=aggregate" \
  -H "Authorization: Bearer $RIFTS_TOKEN"

Das liefert die Zahl der Antworten und für jede Multiple-Choice-Frage, wie viele Leute welche Option gewählt haben. So siehst du, wie viele „Call me“ gewählt haben. Bei Freitextfragen gibt es nur die Zahl der Antworten, nie den Text.

Die Umfrage nach der Veranstaltung schließen

Wenn das Follow-up vorbei ist, meist ein paar Tage nach der Veranstaltung, schließ die Umfrage, damit sie keine Antworten mehr annimmt:

curl -X PATCH https://rifts.to/api/v1/surveys/fuzzy-sleepy-tornado \
  -H "Authorization: Bearer $RIFTS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"closed"}'

Lass das Skript danach noch einmal laufen, um alles aus den letzten Minuten weiterzuleiten. Um wieder Antworten anzunehmen, schick {"status":"open"}. Eine abgelaufene Umfrage lässt sich auf diesem Weg nicht wieder öffnen.

Token und Leads schützen

  • Leite nur weiter, was der Vertrieb braucht. Das Skript schickt drei Antworten (E-Mail, Telefonnummer und beste Zeit für einen Anruf) und woher der Lead kommt, nicht jede Antwort.
  • Protokollier keine Kontaktdaten. Das Skript gibt nur Zahlen aus, und sent-responses.json enthält nur Antwort-IDs. Bleib auch beim Debuggen dabei: Logs landen an mehr Orten als das CRM.
  • Ersetz ein geleaktes Token. Landet ein Token in einem Repository oder einem geteilten Dokument, widerruf es in deinen Kontoeinstellungen und erstell ein neues. Ein Konto kann bis zu 10 Tokens haben, also gib jedem Skript ein eigenes.
  • Meld dich nur so, wie es gewünscht ist. Die Antwort auf „What should we do next?“ (Was sollen wir als Nächstes tun?) ist die eigene Bitte der Person. Die Regeln für Vertriebsanrufe, SMS und Werbe-Mails unterscheiden sich von Land zu Land, also sprich mit der Person, die bei euch für Compliance zuständig ist, bevor die Leads in eine Kampagne gehen.

Häufige Fragen

Verschickt rifts.to Webhooks, oder gibt es eine Zapier-App?

Nein. Das Skript fragt regelmäßig ab, und ein Webhook in Zapier oder Make oder die API deines CRMs nimmt entgegen, was es schickt.

Warum sieht eine Telefonnummer anders aus als im CSV-Export?

Die API liefert Antworten genau so, wie sie abgeschickt wurden. Der führende Apostroph, den der CSV-Export vor Antworten setzt, die mit + beginnen, schützt Tabellenkalkulationen und fehlt in der API.

Geht das auch ohne Code?

Ja, mit Claude und dem rifts.to-MCP-Connector, der ebenfalls zu Pro gehört. Umfrage-Leads mit Claude ins CRM bringen zeigt, wie. Das Skript passt zu einem Team, bei dem die Übergabe ohne Aufsicht laufen soll.

Auch eine Pipeline braucht Leute, die den Code scannen

Das Skript bewegt Leads. Erzeugen kann es keine, und eine leere Umfrage synchronisiert sich tadellos. Wie im ersten Teil dieser Serie:

  • Sag es laut. „Scannt das und wählt ‚Call me‘, wenn ihr diese Woche reden wollt“ holt mehr Handys raus als ein Code auf einer Folie, neben dem kein Wort steht.
  • Nenn einen Grund. Sag, was die Leute bekommen: die Folien, einen Anruf zu der Zeit, die sie wählen, und sonst nichts.
  • Stell den Code dahin, wo die Leute stehen bleiben. Die letzte Folie während der Fragerunde, ein Schild auf Augenhöhe am Stand und der Link, gedruckt unter dem Code, für alle, deren Kamera nicht scharf stellt.
  • Schick ihn noch einmal. Setz denselben Link am nächsten Morgen in die Mail nach der Veranstaltung und lass das Skript eingeplant, bis keine Antworten mehr kommen. So erreicht ein „Call me“ aus der Mail den Vertrieb genauso schnell wie eins vom Stand. Antworten auf eine Umfrage nach der Veranstaltung bekommen erklärt das Timing.

Verwandte Tools

rifts.to kostenlos testen →
rifts.to

Umfrage-Assistent

Baut und startet deine Umfragen

Frag das Team, welcher Tag fürs Standup passt

Fertig. Standup-Tag läuft mit 3 Fragen. Hier teilen: rifts.to/de/s/sunny-otter-quartz

Und, wie lief's?

18 Leute haben geantwortet.

  • Montag: 11
  • Dienstag: 4
  • Mittwoch: 3

Also Montag.

Der Assistent baut und startet deine Umfragen für dich. Leg ein Konto an und probier es aus.

Kostenloses Konto anlegen
Umfrageantworten mit der rifts.to-API ins CRM synchronisieren | rifts.to