Contratos
Convenções
Datas, telefone, e-mail, dinheiro, campos personalizados e a regra de null vs ausente no PATCH.
As regras que valem em toda a API, e que quase sempre são descobertas tarde: formato de data, o que a gente faz com o telefone que você manda, como limpar um campo, e onde a validação existe — e onde ela não existe.
Datas
Saída: sempre ISO-8601 UTC
Toda data que a API devolve é ISO-8601 em UTC, com milissegundos e Z: 2026-09-13T18:04:11.231Z. Uma única exceção: lastDate, dentro de lessons[] e library[] de GET /platform/users/:id, vem como YYYY-MM-DD no fuso America/Sao_Paulo — é um agregado diário, não um instante.
Entrada: onde o fuso é obrigatório
startAt e endAt de reunião (POST /appointments e PATCH /appointments/:id/reschedule) exigem fuso explícito: 2026-09-20T14:00:00Z ou 2026-09-20T11:00:00-03:00. Sem fuso é 400 validation_error — porque 2026-09-20T14:00:00 cru viraria hora local do servidor, que não é a sua.
Já date, from e to de GET /calendars/:id/slots são YYYY-MM-DD (um dia local, não um instante) — qualquer outra coisa é 400. A resposta dessa rota continua em UTC.
Fuso horário
A API não converte fuso em lugar nenhum. O único parâmetro de fuso é tz em GET /calendars/:id/slots — identificador IANA (padrão America/Sao_Paulo, inválido é 400) — e ele define apenas qual dia local calcular. Os startAt/endAt dos horários livres voltam em UTC, com o timezone pedido ecoado na resposta.
Inclusividade dos filtros de data
Não é uniforme, e a diferença muda o resultado de um laço de sincronização. O inventário completo:
| Rota | Parâmetro | Compara |
|---|---|---|
/contacts | createdAfter, updatedAfter | > (não existe *Before aqui) |
/opportunities | createdAfter, updatedAfter, wonAfter, lostAfter, stageChangedAfter | > |
/opportunities | createdBefore, updatedBefore, wonBefore, lostBefore | < |
/opportunities/summary | from / to | >= / < — o intervalo é [from, to) |
/opportunity-events | createdAfter / createdBefore | > / < (sobre o momento da transição) |
/appointments | from / to | >= / < — sobre o horário da reunião |
/appointments | createdAfter, updatedAfter | > |
/interactions | createdAfter / createdBefore | > / < |
/platform/users | activeSince / activeBefore | >= / < |
Duas bordas que devolvem 400: from >= to em /opportunities/summary e to <= from em /appointments. E negócio sem wonAt/lostAt fica de fora de wonAfter/lostAfter por construção.
Telefone
As três operações têm regras diferentes, e é aí que mora a confusão:
- Gravar. POST
/contactsaceita até 20 caracteres no campo; POST/contacts/upsertaceita até 80. O mesmo valor com um espaço a mais pode passar num e dar400no outro. - Buscar. GET
/contacts?phone=compara só os dígitos, dos dois lados, por igualdade exata (o parâmetro aceita de 3 a 40 caracteres). Como a gravação normalizou para55…, buscar11988887777não acha o contato: busque5511988887777. - Deduplicar. O
/contacts/upsertusa a forma canônica brasileira — tira o DDI55e o nono dígito do celular — e, para formas não-BR, casa pelo sufixo comum de 10 dígitos. Aqui5511988887777,11988887777e1188887777são o mesmo contato.
Recomendação que faz as três concordarem: padronize tudo em E.164 sem o sinal (5511988887777), na gravação e na busca.
POST /contacts e PATCH /contacts/:id validam o formato: e-mail malformado é 400 validation_error.
CPF
Campo de texto livre, até 20 caracteres. Não validamos dígito verificador, não aplicamos máscara, não exigimos unicidade e nunca usamos CPF para deduplicar contato (a deduplicação é por telefone e depois por e-mail). Se você pretende cruzar por CPF depois, mande sempre no mesmo formato — só dígitos.
Dinheiro
valueCents é inteiro, em centavos (R$ 1.250,00 = 125000), entre 0 e 1.000.000.000. currency é campo de saída: o servidor devolve, você não envia.
Campos personalizados
Descubra as chaves válidas em GET /custom-fields?entity=contact|opportunity — a resposta traz fieldKey, label, type, required e options (só os valores aceitos). A lista mostra apenas campos ativos e de escopo do workspace.
No negócio: validado
PUT /opportunities/:id/custom-fields faz merge só das chaves enviadas e confere o que recebe: chave desconhecida ou valor fora das options respondem 400 validation_error nomeando a chave.
No contato: aceito sem conferir
Duas notas que valem para os dois lados: o merge nunca remove chave — mandar {"x": null} grava o valor null naquela chave, não a apaga. E o required do catálogo não é imposto à API: ele vale para quem preenche pela tela; uma chave de API pode criar registro sem o campo obrigatório, ou esvaziá-lo, e receber 200.
Ausente vs null no PATCH
Campo ausente do corpo = não mexe. null explícito = limpa. Nem todo campo aceita ser limpo:
| Rota | Aceitam null (limpam) | Não aceitam null |
|---|---|---|
PATCH /contacts/:id | phone, email, company, companyId, cpf, role, notes | name, customFields |
PATCH /opportunities/:id | expectedCloseAt, ownerUserId | title, valueCents, temperature, tagsAdd, tagsRemove |
O que o PATCH não faz
- Etiqueta de contato não se mexe por PATCH: use POST
/contacts/:id/tagscommode: "add" | "remove"(o padrão éadd). Na criação do contato, por outro lado,tagsé aceito direto. - Etiqueta de negócio vai em
tagsAdd/tagsRemovedentro do próprio PATCH. - Estágio de negócio nunca muda por PATCH: use POST
/opportunities/:id/move. E repare que/wine/losetambém movem o negócio — para o estágio de ganho ou de perda do funil.
Origem: origin, source e utm
Três campos parecidos que não querem dizer a mesma coisa:
origin— proveniência de sistema:inbox_whatsapp,inbox_instagram,inbox_facebook,inbox_phone,inbox_email,manual,import,ia,seed,automation,api,form.source— o canal daquela entrada (youtube,lp,trafego…). Existe no negócio e na reunião.utm— as UTMs daquela entrada. No contato é first-touch: gravado só na criação, e um upsert que encontra o contato não sobrescreve. No negócio a UTM é a daquela entrada, nunca herdada do contato.
Ids, arquivamento e tetos de corpo
Ids são strings opacas: não presuma formato, tamanho nem ordem alfabética.
Apagar não existe. DELETE /contacts/:id arquiva (resposta {"archived": true}), e desarquivar só pela tela — não há rota v1 para isso. DELETE /webhooks/:id é a única exclusão de verdade, e leva junto todo o histórico de entregas daquela assinatura.
Dois tetos de corpo, e o menor é o que morde. As escritas que passam pelo Núcleo aceitam corpo HTTP de até 256 KB, mas os argumentos da Ação — o que vai para a trilha de auditoria — não podem passar de 7,5 KB. Os dois respondem 413 payload_too_large, e o segundo chega bem antes. Campos grandes (notes, customFields) são os candidatos naturais a cortar.