Pular para o conteúdo

Sincronizar contatos de um ERP

Manter o ERP como source of truth de clientes e replicar pro CRM é o cenário mais comum em B2B brasileiro. Esse guia mostra o padrão completo, com tratamento de duplicates, batching e auditoria.

Arquitetura

┌─────┐ pull periódico ┌────────┐ upsert API ┌─────┐
│ ERP │ ──────────────────▶ │ Worker │ ──────────────▶ │ CRM │
└─────┘ (cron / queue) └────────┘ (REST) └─────┘
│ logs
┌──────────┐
│ logs DB │
└──────────┘
  • Pull, não push: o ERP raramente tem webhook bom de saída. Polling do worker é mais robusto.
  • Worker próprio: Node.js, Python, Go — qualquer linguagem. Idealmente um serviço dedicado, não embedded no ERP (pra não acoplar deploys).
  • Logs do que mudou: salve no seu lado o lastSyncedAt por cliente — assim você só processa o que mudou.

Quais campos sincronizar

Decida cedo o que é owned by ERP vs owned by CRM:

CampoSource of truthDireção
Razão social / nomeERPERP → CRM (overwrite)
CNPJ / documentoERPERP → CRM (overwrite)
EndereçoERPERP → CRM (overwrite)
Telefone fiscalERPERP → CRM (overwrite)
Email comercialERPERP → CRM (overwrite)
Owner / vendedorCRMNão sobrescrever (preserve)
TagsCRMNão sobrescrever
Score / qualificaçãoCRMNão sobrescrever
Notas e atividadesCRMNão tocar

Regra de ouro: dados frios (cadastrais) vêm do ERP; dados quentes (relacionamento, scoring, owner) ficam no CRM. Misturar gera conflito.

Implementação

  1. Gere uma API Key

    • Nome: ERP Sync — <nome do ERP>
    • Scopes: contacts:read, contacts:write, companies:read, companies:write, properties:read
    • Rate limit: suba pra 500/min se você tem base grande (>10k clientes)
    • Expiração: nunca (essa integração roda em produção indefinidamente)
  2. Cacheie metadata estática

    Ao iniciar o worker, faça uma chamada pra:

    const properties = await api('/properties?objectType=contact')
    const propMap = Object.fromEntries(properties.map(p => [p.internalName, p.id]))

    Recache 1x por dia (cron à meia-noite). Não puxe em toda iteração — propriedades mudam raramente.

  3. Pull do ERP por delta

    No primeiro run, sync completo (todos os clientes). Depois, só delta:

    SELECT * FROM clientes
    WHERE updated_at > :last_sync_at
    ORDER BY updated_at ASC

    Persista last_sync_at no seu DB local. Use ORDER BY updated_at ASC pra processar em ordem cronológica.

  4. Upsert no CRM

    Pra cada cliente:

    async function upsertContact(erpCustomer) {
    // 1. Busca por email no CRM
    const search = await api(`/contacts?search=${encodeURIComponent(erpCustomer.email)}`)
    const existing = search.data.find(c => c.email === erpCustomer.email)
    const payload = {
    firstName: erpCustomer.firstName,
    lastName: erpCustomer.lastName,
    email: erpCustomer.email,
    phone: erpCustomer.phone,
    customProperties: {
    [propMap.cnpj]: erpCustomer.cnpj,
    [propMap.codigo_erp]: erpCustomer.id, // referência cruzada!
    [propMap.cidade]: erpCustomer.cidade,
    },
    }
    if (existing) {
    // PATCH — mantém ownerId, tags, score
    return api(`/contacts/${existing.id}`, { method: 'PATCH', body: payload })
    } else {
    try {
    return await api('/contacts', { method: 'POST', body: payload })
    } catch (err) {
    if (err.error === 'duplicate_email') {
    // Race condition: alguém criou no meio. Retry como PATCH.
    return api(`/contacts/${err.existingContactId}`, { method: 'PATCH', body: payload })
    }
    throw err
    }
    }
    }
  5. Throttle e error handling

    for (const customer of customers) {
    try {
    await upsertContact(customer)
    } catch (err) {
    if (err.status === 429) {
    const wait = err.retryAfterSeconds ?? 1
    await sleep(wait * 1000)
    await upsertContact(customer) // retry uma vez
    } else if (err.status >= 500) {
    logError(customer, err) // CRM com problema — não bloqueia o sync
    } else {
    logError(customer, err) // erro de validação — investigar manualmente
    }
    }
    await sleep(50) // 50ms entre chamadas — 1200 ops/min sustentado, abaixo de 1000 reais (margem de segurança)
    }
  6. Audit no seu lado

    Em paralelo ao log de chamadas do CRM, persista local:

    CREATE TABLE crm_sync_log (
    id SERIAL PRIMARY KEY,
    erp_customer_id TEXT NOT NULL,
    crm_contact_id TEXT,
    action TEXT, -- 'created', 'updated', 'skipped', 'error'
    payload JSONB,
    error_message TEXT,
    synced_at TIMESTAMP DEFAULT NOW()
    );

    Vai te salvar quando alguém perguntar “por que esse cliente apareceu duplicado?” 3 meses depois.

Reconciliação periódica

Mesmo com sync robusto, divergências aparecem (cliente deletado no ERP mas continua no CRM, race conditions, etc.). Rode 1x por semana um script de reconciliação:

-- IDs do ERP vs IDs no CRM (codigo_erp custom prop)
WITH erp_ids AS (SELECT id FROM clientes_erp),
crm_codigos AS (
SELECT (custom_properties->>'codigo_erp')::text AS erp_id
FROM crm_contacts WHERE custom_properties->>'codigo_erp' IS NOT NULL
)
SELECT 'só no ERP' AS where, e.id FROM erp_ids e WHERE NOT EXISTS (SELECT 1 FROM crm_codigos c WHERE c.erp_id = e.id)
UNION ALL
SELECT 'só no CRM' AS where, c.erp_id FROM crm_codigos c WHERE NOT EXISTS (SELECT 1 FROM erp_ids e WHERE e.id = c.erp_id);

Envie o resultado num e-mail/Slack pro time decidir: criar manualmente? deletar? merge?

Quando NÃO sincronizar

Algumas dicas pra evitar dor:

  • Não sincronize “todos os clientes” se o CRM é só pra prospecção. Filtra por status no ERP (ex: só clientes ativos, ou só clientes com receita > X)
  • Não sincronize histórico antigo. Considere um cutoff (ex: só clientes criados nos últimos 5 anos)
  • Cuidado com PII desnecessário. Não importe CPF / dados sensíveis se a operação do CRM não precisa — você cria risco LGPD sem benefício