Sync Survey Responses to Your CRM With the rifts.to API
2026-11-02
What this script does
Earlier parts of this series moved booth leads into a CRM after the event, first with a CSV import and then by asking Claude, both part of Pro. This part does it during the event: a small script reads the survey every few minutes and sends each new "call me" lead to your CRM, so someone at the office can phone a person who is still in the building.
If you can open a terminal, set an environment variable and paste a webhook URL, you can run it. The script is plain JavaScript with no packages to install.
Before you start
- A Pro account. The rifts.to API is part of Pro, and the subscription is checked on every call.
- An API token. Create one in your account settings and name it, for example "Booth lead sync". It is shown once, so copy it straight into an environment variable called
RIFTS_TOKEN, never into browser code or a shared document. A token acts on your whole account, not one survey. - Somewhere to send leads. An incoming webhook in Zapier or Make that creates a CRM lead, or your CRM's own API. Its URL goes in
CRM_WEBHOOK_URL. - Node.js 18 or later, which has
fetchbuilt in.
Every request sends the token in a header, Authorization: Bearer rifts_pat_.... A token in a query string is refused with 401.
Find the survey id
The survey id is the last part of its public link. For https://rifts.to/en/s/fuzzy-sleepy-tornado, the id is fuzzy-sleepy-tornado. You can also list your surveys:
curl https://rifts.to/api/v1/surveys \
-H "Authorization: Bearer $RIFTS_TOKEN"
Put the id in an environment variable called SURVEY_ID.
Or create the lead survey from the script
If you have not built the survey yet, POST /api/v1/surveys creates it. These questions follow lead qualification questions to ask in a survey, and the contact questions use Pro answer rules so they are required only for the people who asked for follow-up.
curl -X POST https://rifts.to/api/v1/surveys \
-H "Authorization: Bearer $RIFTS_TOKEN" \
-H "Content-Type: application/json" \
-d @lead-survey.json
Where lead-survey.json holds:
{
"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"] }] } } }
]
}
A condition points at an earlier multiple choice question by position, counting from 0, and its values must match that question's options exactly. Rules never hide a question: everyone sees all six, and a rule only decides whether an answer is required. That is why the labels say who each question is for. See conditional and optional survey questions.
The response includes the survey's id, its public url for the QR code, and an admin_url. Keep the admin link private. It is how you watch results live, and the read endpoint never returns it.
What a survey read returns
GET /api/v1/surveys/{id} returns the questions and every response:
{
"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 are stored by question position. To get someone's answer to a question, read answers[q.index], where q is the question object. A multiple choice answer is the option text, and a free-text answer is exactly what the person typed. A skipped optional question has no value. status is open, closed or expired.
The script finds each question by the start of its text, not a fixed position, so it still works if someone adds a question later.
The script
Save this as sync-leads.mjs. Change the question text in QUESTIONS and the CALL_ME option if your survey words them differently.
// 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);
});
It reads the survey, finds the four questions, loads the response ids already sent from sent-responses.json, and forwards every other "Call me" response as a small JSON object. Errors stop the run with a plain message: 401 for a bad or revoked token, 403 entitlement_required when Pro has lapsed, 404 for a wrong survey id or one on another account (the same answer for both), and 429 over the hourly limit.
Run it every few minutes during the event
Two ways to schedule it:
- On a laptop at the event, run
node sync-leads.mjs --watch. It syncs every 5 minutes and stops by itself once the survey is closed or expired. - On a server, use cron or your platform's scheduled jobs to run it without
--watch. Keep the three variables in a file only you can read, not in the script:
*/5 * * * * cd /home/you/lead-sync && . ./.env && node sync-leads.mjs >> sync.log 2>&1
The API allows 600 calls per hour per token. One run is one call, so every 5 minutes is 12 calls an hour and even every minute is 60.
Why the script tracks response ids
A rifts.to response carries no respondent identity. It holds a random id, the answers and the time it arrived, with no name, account, IP address or cookie. The response id is the only stable thing to de-duplicate on, so the script records each one it sends and skips it next time.
That stops the same response going out twice. It does not stop one person answering twice, after your talk and again at the booth: two responses, two ids, two leads. Match on email address in your CRM, or in your Zapier or Make scenario, before anyone picks up the phone.
Check the count without pulling every answer
For a quick "how many so far?", add ?view=aggregate:
curl "https://rifts.to/api/v1/surveys/fuzzy-sleepy-tornado?view=aggregate" \
-H "Authorization: Bearer $RIFTS_TOKEN"
It returns the response count and, for each multiple choice question, how many people picked each option, so you can see how many chose "Call me". Free-text questions get only a count of answers, never the text.
Close the survey after the event
When follow-up is over, usually a few days after the event, close the survey so it stops taking answers:
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"}'
Run the script once more afterward to forward anything from the last few minutes. To take answers again, send {"status":"open"}. An expired survey cannot be reopened this way.
Keep the token and the leads safe
- Forward only what the salesperson needs. The script sends three answers (email, phone and best time to call) and where the lead came from, not every answer.
- Do not log contact details. The script prints counts only, and
sent-responses.jsonholds response ids and nothing else. Keep it that way when you debug: logs travel further than the CRM. - Rotate a leaked token. If a token lands in a repository or a shared doc, revoke it in your account settings and create a new one. An account holds up to 10 tokens, so give each script its own.
- Follow up only as asked. The "What should we do next?" answer is the person's own request. Rules on sales calls, texts and marketing email differ by country, so check with whoever owns compliance before the leads go into a campaign.
Common questions
Does rifts.to send webhooks, or have a Zapier app?
No. The script does the polling, and a Zapier or Make webhook, or your CRM's API, receives what it sends.
Why does a phone number look different from the CSV export?
The API returns answers exactly as submitted. The leading apostrophe the CSV export adds to answers starting with + is a spreadsheet guard, and it is not there in the API.
Can I run this without writing code?
Yes, with Claude and the rifts.to MCP connector, which is also part of Pro. Send survey leads to your CRM with Claude walks through it. The script suits a team that wants the handoff to run unattended.
A pipeline still needs people to scan the code
The script moves leads. It cannot create them, and an empty survey syncs perfectly. As in the first part of this series:
- Ask out loud. "Scan this and pick Call me if you want to talk this week" gets more phones out than a code on a slide with no words next to it.
- Give a reason. Say what they get: the slides, a call at the time they choose, and nothing else.
- Put the code where people stand still. The last slide during Q&A, a sign at eye level at the booth, and the link printed under the code for anyone whose camera will not focus.
- Send it again. Put the same link in the post-event email the next morning, and keep the script scheduled until the replies stop, so a "call me" from the email reaches sales as fast as one from the booth. Getting responses to a post-event survey covers the timing.