Skip to content

Sincronizar respostas com um CRM ​

O caso mais comum: manter um sistema externo atualizado com as respostas de um formulário, sem perder nada e sem consultar em laço.

A solução tem duas metades que fazem coisas diferentes:

Webhook  ──►  o que acontece de agora em diante  (segundos de latência)
Varredura ─►  o que já existia + conferência periódica

Usar só a primeira perde o histórico. Usar só a segunda chega atrasado e gasta o seu limite de requisições. As duas juntas cobrem os dois lados.

1. Carga inicial ​

Antes de ligar o webhook, traga o que já existe. A varredura por cursor é o caminho — ela tem custo constante por página, do começo ao fim.

js
import { salvarNoCrm } from './crm.js';

const BASE = 'https://api.edusati.com/v1';
const headers = { Authorization: `Bearer ${process.env.EDUSATI_KEY}` };

async function* varrerRespostas(formId, { from, to } = {}) {
  let cursor = null;
  do {
    const url = new URL(`${BASE}/forms/${formId}/responses`);
    url.searchParams.set('include', 'answers');
    url.searchParams.set('limit', '100');
    if (cursor) url.searchParams.set('cursor', cursor);
    if (from) url.searchParams.set('from', from);
    if (to) url.searchParams.set('to', to);

    const res = await fetch(url, { headers });
    if (res.status === 429) {                            // respeita o limite
      await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 60) * 1000));
      continue;                                          // repete a MESMA página
    }
    if (!res.ok) throw new Error(`Edusati ${res.status}`);

    const { data, meta } = await res.json();
    yield* data;
    cursor = meta.hasMore ? meta.nextCursor : null;
  } while (cursor);
}

for await (const resposta of varrerRespostas(FORM_ID)) {
  await salvarNoCrm(resposta);
}

O continue no 429 é importante: ele repete a mesma página, sem avançar o cursor. Avançar depois de um erro é como se perde registro em silêncio.

2. Grave de forma idempotente ​

Esta é a peça que faz tudo o mais ser seguro. Use responseId como chave natural:

js
export async function salvarNoCrm(resposta) {
  await db.query(
    `INSERT INTO respostas_pesquisa (edusati_id, matricula, nota_nps, comentario, respondido_em)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (edusati_id) DO UPDATE SET
       nota_nps      = EXCLUDED.nota_nps,
       comentario    = EXCLUDED.comentario,
       respondido_em = EXCLUDED.respondido_em`,
    [
      resposta.id,
      resposta.respondent?.externalId ?? null,
      resposta.answers?.q_nps ?? null,
      resposta.answers?.q_comentario ?? null,
      resposta.completedAt,
    ],
  );
}

Com o ON CONFLICT, processar a mesma resposta duas vezes é inofensivo. Isso libera você de manter uma tabela de eventos já vistos — o webhook duplicado simplesmente reescreve a mesma linha com os mesmos valores.

externalId é a sua chave de ligação

Se você criou os convites pela API e informou externalId (a matrícula, o id do cliente no seu CRM), ele volta em respondent.externalId — na resposta e em todo evento. É como amarrar os dois lados sem tabela de-para.

3. Ligue o webhook ​

Cadastre o endpoint assinando response.created e trate cada evento com a mesma função da carga inicial:

js
import express from 'express';
import { assinaturaValida } from './assinatura.js';   // ver /webhooks/assinatura
import { salvarNoCrm } from './crm.js';

const app = express();

app.post('/edusati', express.raw({ type: 'application/json' }), async (req, res) => {
  const corpoBruto = req.body.toString('utf8');
  if (!assinaturaValida(req.get('x-edusati-signature'), corpoBruto)) {
    return res.sendStatus(401);
  }

  const evento = JSON.parse(corpoBruto);
  res.sendStatus(200);                     // confirma ANTES de processar

  if (evento.type !== 'response.created') return;

  // O payload do evento já tem o mesmo formato da listagem
  await salvarNoCrm({
    id: evento.data.responseId,
    respondent: evento.data.respondent,
    answers: evento.data.answers,
    completedAt: evento.data.completedAt,
  });
});

Note que o res.sendStatus(200) vem antes do trabalho. Em produção, o correto é enfileirar e processar num worker — se salvarNoCrm falhar depois de você ter confirmado, a reconciliação do passo 4 recupera.

4. Reconcilie periodicamente ​

Webhook é entrega pela internet: seu servidor pode ter ficado fora do ar, um endpoint pode ter sido desativado por falhas seguidas, um evento pode ter sido barrado pelo teto horário.

Uma varredura diária dos últimos dias fecha essas lacunas:

js
// Roda de madrugada. Janela generosa: o custo é baixo e o UPSERT torna repetir inofensivo.
const ontem = new Date(Date.now() - 3 * 86400_000).toISOString().slice(0, 10);

for await (const resposta of varrerRespostas(FORM_ID, { from: ontem })) {
  await salvarNoCrm(resposta);
}

Três dias de sobreposição cobrem um fim de semana de indisponibilidade. Como a gravação é idempotente, reprocessar o que já está lá não custa nada além do tempo.

Erros comuns ​

Varrer em laço em vez de assinar o webhook. Consultar a cada minuto gasta o seu limite e ainda chega até um minuto atrasado. Um webhook chega em segundos e não consome requisição nenhuma.

Usar a varredura para o incremental. A ordem é do mais recente para o mais antigo — uma resposta criada durante a varredura entra acima do cursor e não aparece nela. Isso é bom (nada é pulado nem repetido dentro de uma passagem), mas significa que varredura serve para histórico, não para "o que há de novo".

Avançar o cursor depois de um erro. Se a página falhou, repita a mesma página. O continue do exemplo faz isso.

Confiar no total para saber quando parar. Ele vem só na primeira página e é o retrato daquele instante. Pare em hasMore: false.

Não validar a assinatura. Sua URL é pública. Sem validação, qualquer um injeta registros falsos no seu CRM.

Variação: só o que interessa ​

Se você acompanha um formulário com muito volume mas só quer agir sobre casos específicos (um NPS detrator, por exemplo), filtre no seu handler — o evento já traz as respostas, então não há chamada extra:

js
if (evento.type === 'response.created') {
  const nps = evento.data.answers?.q_nps;
  if (typeof nps === 'number' && nps <= 6) {
    await abrirTicketDeRetencao(evento.data.respondent, nps);
  }
}

Uma alternativa sem código: regras de alerta no painel disparam notificação, e-mail ou push quando uma resposta casa com uma condição. Se o objetivo é avisar uma pessoa (e não alimentar um sistema), costuma ser o caminho mais curto.

Documentação da API pública do Edusati