用 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”,也能和展位上的一样快地送到销售手里。怎样做一份会后问卷并真的收到回答讲了发送的时机。