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
lastSyncedAtpor cliente — assim você só processa o que mudou.
Quais campos sincronizar
Decida cedo o que é owned by ERP vs owned by CRM:
| Campo | Source of truth | Direção |
|---|---|---|
| Razão social / nome | ERP | ERP → CRM (overwrite) |
| CNPJ / documento | ERP | ERP → CRM (overwrite) |
| Endereço | ERP | ERP → CRM (overwrite) |
| Telefone fiscal | ERP | ERP → CRM (overwrite) |
| Email comercial | ERP | ERP → CRM (overwrite) |
| Owner / vendedor | CRM | Não sobrescrever (preserve) |
| Tags | CRM | Não sobrescrever |
| Score / qualificação | CRM | Não sobrescrever |
| Notas e atividades | CRM | Nã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
-
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)
- Nome:
-
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.
-
Pull do ERP por delta
No primeiro run, sync completo (todos os clientes). Depois, só delta:
SELECT * FROM clientesWHERE updated_at > :last_sync_atORDER BY updated_at ASCPersista
last_sync_atno seu DB local. UseORDER BY updated_at ASCpra processar em ordem cronológica. -
Upsert no CRM
Pra cada cliente:
async function upsertContact(erpCustomer) {// 1. Busca por email no CRMconst 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, scorereturn 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}}} -
Throttle e error handling
for (const customer of customers) {try {await upsertContact(customer)} catch (err) {if (err.status === 429) {const wait = err.retryAfterSeconds ?? 1await 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)} -
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 ALLSELECT '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