Sincroniza las respuestas de una encuesta con tu CRM usando la API de rifts.to
2026-11-02
Qué hace este script
Las partes anteriores de esta serie pasaron los leads del stand a un CRM después del evento, primero con una importación de CSV y después pidiéndoselo a Claude, las dos parte de Pro. Esta parte lo hace durante el evento: un script corto lee la encuesta cada pocos minutos y manda cada lead nuevo de "Call me" (llámame) a tu CRM, para que alguien en la oficina pueda llamar a una persona que todavía está en el recinto.
Si sabes abrir una terminal, definir una variable de entorno y pegar la URL de un webhook, puedes correrlo. El script es JavaScript simple, sin paquetes que instalar.
Antes de empezar
- Una cuenta Pro. La API de rifts.to es parte de Pro, y la suscripción se comprueba en cada llamada.
- Un token de API. Crea uno en la configuración de tu cuenta y ponle un nombre, por ejemplo "Sincronización de leads del stand". Se muestra una sola vez, así que cópialo directo a una variable de entorno llamada
RIFTS_TOKEN, nunca a código de navegador ni a un documento compartido. Un token actúa sobre toda tu cuenta, no sobre una sola encuesta. - Un lugar adonde mandar los leads. Un webhook entrante en Zapier o Make que cree un lead en el CRM, o la API de tu propio CRM. Su URL va en
CRM_WEBHOOK_URL. - Node.js 18 o posterior, que ya trae
fetchincorporado.
Cada solicitud manda el token en un encabezado, Authorization: Bearer rifts_pat_.... Un token en la query string se rechaza con 401.
Encuentra el id de la encuesta
El id de la encuesta es la última parte de su enlace público. Para https://rifts.to/en/s/fuzzy-sleepy-tornado, el id es fuzzy-sleepy-tornado. También puedes listar tus encuestas:
curl https://rifts.to/api/v1/surveys \
-H "Authorization: Bearer $RIFTS_TOKEN"
Pon el id en una variable de entorno llamada SURVEY_ID.
O crea la encuesta de leads desde el script
Si todavía no armaste la encuesta, POST /api/v1/surveys la crea. Estas preguntas siguen preguntas para calificar leads en una encuesta, y las preguntas de contacto usan reglas de respuesta de Pro para que sean obligatorias solo para quienes pidieron seguimiento.
curl -X POST https://rifts.to/api/v1/surveys \
-H "Authorization: Bearer $RIFTS_TOKEN" \
-H "Content-Type: application/json" \
-d @lead-survey.json
Donde lead-survey.json contiene:
{
"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"] }] } } }
]
}
Una condición apunta a una pregunta de opción múltiple anterior por su posición, contando desde 0, y sus valores tienen que coincidir exactamente con las opciones de esa pregunta. Las reglas nunca esconden una pregunta: todo el mundo ve las seis, y una regla solo decide si una respuesta es obligatoria. Por eso las etiquetas dicen para quién es cada pregunta. Mira preguntas condicionales y opcionales.
La respuesta incluye el id de la encuesta, su url pública para el código QR y un admin_url. Guarda en privado el enlace de administrador. Es con el que ves los resultados en vivo, y el endpoint de lectura nunca lo devuelve.
Qué devuelve una lectura de la encuesta
GET /api/v1/surveys/{id} devuelve las preguntas y todas las respuestas:
{
"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": [ ... ] }
]
}
Las respuestas se guardan por posición de la pregunta. Para obtener la respuesta de alguien a una pregunta, lee answers[q.index], donde q es el objeto de la pregunta. Una respuesta de opción múltiple es el texto de la opción, y una de texto libre es exactamente lo que la persona escribió. Una pregunta opcional que alguien saltó no tiene valor. status es open, closed o expired.
El script encuentra cada pregunta por el comienzo de su texto, no por una posición fija, así que sigue funcionando si alguien agrega una pregunta después.
El script
Guárdalo como sync-leads.mjs. Cambia el texto de las preguntas en QUESTIONS y la opción CALL_ME si tu encuesta las redacta distinto, por ejemplo en español.
// 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);
});
Lee la encuesta, encuentra las cuatro preguntas, carga desde sent-responses.json los id de respuesta que ya mandó, y reenvía cada otra respuesta "Call me" como un objeto JSON pequeño. Los errores detienen la ejecución con un mensaje claro: 401 si el token está mal o fue revocado, 403 entitlement_required cuando Pro se venció, 404 si el id de la encuesta está mal o es de otra cuenta (la misma respuesta para los dos casos), y 429 si pasas el límite por hora.
Córrelo cada pocos minutos durante el evento
Dos formas de programarlo:
- En una laptop en el evento, corre
node sync-leads.mjs --watch. Sincroniza cada 5 minutos y se detiene solo cuando la encuesta se cierra o vence. - En un servidor, usa cron o las tareas programadas de tu plataforma para correrlo sin
--watch. Guarda las tres variables en un archivo que solo tú puedas leer, no en el script:
*/5 * * * * cd /home/you/lead-sync && . ./.env && node sync-leads.mjs >> sync.log 2>&1
La API permite 600 llamadas por hora por token. Cada ejecución es una llamada, así que cada 5 minutos son 12 llamadas por hora, y aun cada minuto son 60.
Por qué el script lleva la cuenta de los id de respuesta
Una respuesta de rifts.to no lleva la identidad de quien responde. Tiene un id aleatorio, las respuestas y la hora en que llegó, sin nombre, cuenta, dirección IP ni cookie. El id de respuesta es lo único estable para quitar duplicados, así que el script anota cada uno que manda y lo salta la próxima vez.
Eso evita que la misma respuesta salga dos veces. No evita que una persona responda dos veces, después de tu charla y otra vez en el stand: dos respuestas, dos id, dos leads. Cruza por correo en tu CRM, o en tu escenario de Zapier o Make, antes de que alguien levante el teléfono.
Consulta el conteo sin traer todas las respuestas
Para un "¿cuántas van?" rápido, agrega ?view=aggregate:
curl "https://rifts.to/api/v1/surveys/fuzzy-sleepy-tornado?view=aggregate" \
-H "Authorization: Bearer $RIFTS_TOKEN"
Devuelve el conteo de respuestas y, para cada pregunta de opción múltiple, cuánta gente eligió cada opción, así que puedes ver cuántas personas eligieron "Call me". Las preguntas de texto libre solo traen un conteo de respuestas, nunca el texto.
Cierra la encuesta después del evento
Cuando termine el seguimiento, normalmente unos días después del evento, cierra la encuesta para que deje de recibir respuestas:
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"}'
Corre el script una vez más después, para reenviar lo que haya llegado en los últimos minutos. Para volver a recibir respuestas, manda {"status":"open"}. Una encuesta vencida no se puede reabrir de esta forma.
Cuida el token y los leads
- Reenvía solo lo que necesita ventas. El script manda tres respuestas (correo, teléfono y mejor horario para llamar) y de dónde viene el lead, no todas las respuestas.
- No registres datos de contacto en los logs. El script imprime solo conteos, y
sent-responses.jsonguarda id de respuesta y nada más. Mantenlo así cuando depures: los logs viajan más lejos que el CRM. - Cambia un token filtrado. Si un token termina en un repositorio o en un documento compartido, revócalo en la configuración de tu cuenta y crea uno nuevo. Una cuenta puede tener hasta 10 tokens, así que dale a cada script el suyo.
- Haz seguimiento solo como te lo pidieron. La respuesta a "What should we do next?" (¿qué hacemos ahora?) es el pedido de la propia persona. Las reglas sobre llamadas de venta, mensajes de texto y correos de marketing cambian según el país, así que consulta con quien se encargue del cumplimiento normativo antes de que los leads entren a una campaña.
Preguntas frecuentes
¿rifts.to manda webhooks, o tiene una app de Zapier?
No. El script hace las consultas periódicas, y un webhook de Zapier o Make, o la API de tu CRM, recibe lo que manda.
¿Por qué un teléfono se ve distinto que en la exportación a CSV?
La API devuelve las respuestas exactamente como se enviaron. El apóstrofo que la exportación a CSV agrega al inicio de las respuestas que empiezan con + es una protección para hojas de cálculo, y en la API no está.
¿Puedo hacer esto sin escribir código?
Sí, con Claude y el conector MCP de rifts.to, que también es parte de Pro. Pasa los leads de tu encuesta al CRM con Claude explica cómo. El script es para un equipo que quiere que el traspaso corra sin nadie atendiéndolo.
Un pipeline igual necesita que la gente escanee el código
El script mueve leads. No puede crearlos, y una encuesta vacía se sincroniza perfectamente. Como en la primera parte de esta serie:
- Pídelo en voz alta. "Escanea esto y elige Call me si quieres que hablemos esta semana" hace que saquen más celulares que un código en una diapositiva sin nada escrito al lado.
- Da una razón. Di qué se llevan: las diapositivas, una llamada a la hora que elijan, y nada más.
- Pon el código donde la gente se queda quieta. La última diapositiva durante las preguntas del público, un cartel a la altura de los ojos en el stand, y el enlace impreso debajo del código para quien tenga una cámara que no enfoque.
- Vuelve a mandarlo. Pon el mismo enlace en el correo posterior al evento a la mañana siguiente, y deja el script programado hasta que dejen de llegar respuestas, para que un "Call me" que llega desde el correo le llegue a ventas tan rápido como uno del stand. Cómo hacer una encuesta post-evento (y que de verdad respondan) explica cuándo mandarlo.