rifts.to
← 所有文章

用 rifts.to API 把问卷答卷同步到 CRM

2026-11-02

这段脚本做什么

本系列前几篇是在活动结束后把展位线索送进 CRM,先是用 CSV 导入,然后是让 Claude 来做,两者都属于 Pro。这一篇在活动进行中就完成交接:一段小脚本每隔几分钟读取一次问卷,把每条新的“Call me”(给我打电话)线索发到你的 CRM,这样办公室里的同事就能给还在会场里的人打电话。

只要你会打开终端、设置环境变量、粘贴一个 webhook 地址,就能运行它。脚本是纯 JavaScript,不用安装任何依赖包。

开始之前

  • 一个 Pro 账户。rifts.to API 属于 Pro,每次调用都会检查订阅。
  • 一个 API 令牌。在账户设置里创建一个并起个名字,比如“Booth lead sync”。它只显示一次,所以直接把它复制进一个名为 RIFTS_TOKEN 的环境变量,千万不要放进浏览器端代码或共享文档。令牌作用于你的整个账户,而不是某一份问卷。
  • 一个接收线索的地方。Zapier 或 Make 里一个能创建 CRM 线索的传入 webhook,或者你的 CRM 自己的 API。它的地址放进 CRM_WEBHOOK_URL。
  • Node.js 18 或更高版本,其中内置了 fetch。

每个请求都在请求头里发送令牌:Authorization: Bearer rifts_pat_...。放在查询字符串里的令牌会被拒绝,返回 401。

找到问卷 ID

问卷 ID 就是公开链接的最后一段。对于 https://rifts.to/en/s/fuzzy-sleepy-tornado,ID 是 fuzzy-sleepy-tornado。你也可以列出自己的所有问卷:

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

把 ID 放进一个名为 SURVEY_ID 的环境变量。

或者用脚本创建线索问卷

如果你还没建问卷,POST /api/v1/surveys 可以创建它。这些问题参照了问卷里该问哪些线索筛选问题,联系方式题用了 Pro 的作答规则,只对要求跟进的人必答。

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

其中 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"] }] } } }
  ]
}

条件按位置指向前面的某道单选题,从 0 开始计数,它的 values 必须和那道题的选项一字不差。规则从不隐藏问题:每个人都能看到全部六道题,规则只决定某个回答是否必填。所以题目里的说明要写清楚每道题是给谁的。见条件题和选填题。

返回结果里有问卷的 id、用来生成二维码的公开 url,以及一个 admin_url。管理链接要保密。你靠它实时查看结果,而读取接口永远不会返回它。

读取问卷会返回什么

GET /api/v1/surveys/{id} 返回问题和所有答卷:

{
  "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": [ ... ] }
  ]
}

回答按题目位置存储。要取得某人对某道题的回答,读取 answers[q.index],其中 q 是题目对象。单选题的回答是选项文字,自由填写题的回答就是对方原样打出来的内容。跳过的选填题没有值。status 的取值是 open、closed 或 expired。

脚本按题目文字的开头来找题,而不是按固定位置,所以以后有人加了题,它照样能用。

脚本

把下面的内容保存为 sync-leads.mjs。如果你问卷里的措辞不一样,就修改 QUESTIONS 里的题目文字和 CALL_ME 选项。

// 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);
});

它读取问卷,找到这四道题,从 sent-responses.json 载入已经发送过的答卷 ID,再把其余每一份“Call me”答卷作为一个小小的 JSON 对象转发出去。出错时,运行会停下并给出一句明白的提示:令牌无效或已撤销返回 401;Pro 已过期返回 403 entitlement_required;问卷 ID 写错、或者问卷在别的账户下返回 404(两种情况返回的结果相同);超出每小时限额返回 429。

活动期间每隔几分钟运行一次

有两种安排方式:

  • 在活动现场的笔记本电脑上,运行 node sync-leads.mjs --watch。它每 5 分钟同步一次,问卷关闭或过期后会自己停下。
  • 在服务器上,用 cron 或你所用平台的定时任务来运行它,不加 --watch。把三个变量放在一个只有你能读的文件里,不要写进脚本:
*/5 * * * * cd /home/you/lead-sync && . ./.env && node sync-leads.mjs >> sync.log 2>&1

API 允许每个令牌每小时调用 600 次。运行一次就是一次调用,所以每 5 分钟一次是每小时 12 次,就算每分钟一次也才 60 次。

为什么脚本要记录答卷 ID

rifts.to 的答卷不带作答者身份。它只有一个随机 ID、回答内容和到达时间,没有姓名、账户、IP 地址或 Cookie。答卷 ID 是唯一可以用来去重的稳定信息,所以脚本会记下发送过的每一个,下次就跳过。

这能防止同一份答卷被发出两次,但防不了同一个人答两次,比如听完你的演讲答一次、到展位又答一次:两份答卷,两个 ID,两条线索。在有人拿起电话之前,先在你的 CRM 里,或者在 Zapier 或 Make 的流程里,按邮箱地址匹配一遍。

不拉取所有回答也能查看数量

想快速知道“到现在有多少了?”,就加上 ?view=aggregate:

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

它返回答卷数量,以及每道单选题上每个选项各有多少人选,这样你就能看到有多少人选了“Call me”。自由填写题只返回回答的数量,从不返回内容。

活动结束后关闭问卷

跟进结束后,通常是活动过后几天,关闭问卷,让它不再接收回答:

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

关闭之后再运行一次脚本,把最后几分钟里收到的内容转发出去。想重新接收回答,就发送 {"status":"open"}。已过期的问卷不能用这种方式重新打开。

保护好令牌和线索

  • 只转发销售需要的内容。脚本只发三项回答(邮箱、电话和方便通话的时间)以及线索来源,而不是所有回答。
  • 不要在日志里记录联系方式。脚本只打印数量,sent-responses.json 里也只有答卷 ID,别无其他。调试时也要保持这样:日志流转的范围比 CRM 更广。
  • 令牌泄露了就换掉。如果令牌进了代码仓库或共享文档,就在账户设置里撤销它,再新建一个。一个账户最多可以有 10 个令牌,所以给每个脚本各配一个。
  • 只按对方的要求跟进。“What should we do next?”(接下来我们该做什么?)的回答是对方自己提出的要求。销售电话、短信和营销邮件的规定因国家而异,所以线索进入营销活动之前,先和负责合规的人确认一下。

常见问题

rifts.to 会发送 webhook 吗?有 Zapier 应用吗?

都没有。轮询由脚本来做,接收它所发内容的是 Zapier 或 Make 的 webhook,或者你的 CRM 的 API。

为什么电话号码看起来和 CSV 导出里的不一样?

API 按提交时的原样返回回答。CSV 导出给以 + 开头的回答前面加的撇号,是一道防电子表格公式的保护,API 里没有它。

不写代码能做到吗?

能,用 Claude 和 rifts.to MCP 连接器,它同样属于 Pro。用 Claude 把问卷线索送进 CRM一步步讲了做法。这段脚本适合希望交接过程无人值守的团队。

流水线再好,也得有人扫码

脚本只负责搬运线索,它变不出线索,一份空问卷也能同步得毫无差错。和本系列第一篇说的一样:

  • 开口请大家扫。“扫这个,想在这周聊聊的话选 Call me”,比幻灯片上一个旁边一个字都没有的二维码,能让更多人掏出手机。
  • 给一个理由。说清楚他们能得到什么:幻灯片、一个在他们自己选的时间打来的电话,仅此而已。
  • 把二维码放在大家会站定的地方。问答环节的最后一页幻灯片、展位上与视线齐平的牌子,再在二维码下面印上链接,方便相机对不上焦的人。
  • 再发一次。第二天早上把同一个链接放进会后邮件,并让脚本一直按计划运行到不再有回复为止,这样从邮件里来的“Call me”,也能和展位上的一样快地送到销售手里。怎样做一份会后问卷并真的收到回答讲了发送的时机。

相关工具

免费试用 rifts.to →
rifts.to

问卷助手

帮你建好并发起问卷

问问团队,站会安排在哪天合适

建好了。站会日期投票已发布,共 3 道题。 分享链接:rifts.to/zh/s/sunny-otter-quartz

结果怎么样?

有 18 人参与。

  • 周一:11
  • 周二:4
  • 周三:3

那就定周一。

助手会帮你建好并发起问卷。注册一个账户就能试用。

免费注册账户
用 rifts.to API 把问卷答卷同步到 CRM | rifts.to