Contatos
O Contato é a pessoa — candidato, aluno, responsável. Um cadastro só, que atravessa a jornada inteira. Esta página cobre criar, atualizar e sincronizar contatos a partir do seu sistema.
Todos os caminhos são relativos a API_BASE/public/v1/crm, e toda requisição leva a API
key: Authorization: Bearer wf_sua_chave_aqui.
Identidade — leia antes de integrar
O contato é único dentro da instituição, e a sua API key já diz qual é. Três propriedades identificam um contato de forma única, e são as que você pode usar para endereçar e para sincronizar:
| Propriedade | O que é |
|---|---|
externalId | O id do contato no seu sistema. É a que você deve preferir |
email | |
taxId | CPF ou CNPJ |
externalIdnão é o id do CRM de onde você está migrando. É o id no sistema que está chamando esta API. Gravar o Record ID do HubSpot aqui faz a próxima integração apontar para o lugar errado.
phone não é único — mais de um contato pode ter o mesmo telefone, inclusive na mesma
instituição, e por isso não está entre as propriedades de idProperty.
Criar um contato
POST /contacts · síncrono
curl -X POST "API_BASE/public/v1/crm/contacts" \
-H "Authorization: Bearer wf_sua_chave_aqui" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"name": "Ana Souza",
"email": "ana.souza@exemplo.com",
"phone": "+5511999999999",
"taxId": "12345678901",
"externalId": "aluno-4471",
"interesse_em_bolsa": "Sim"
}
}'
Tudo vai dentro de properties — nativas e customizadas, sem distinção de sintaxe.
| Propriedade | Tipo | Obrigatório | Descrição |
|---|---|---|---|
email | string | condicional | email ou phone é obrigatório — um contato sem nenhum dos dois não é alcançável. O formato de e-mail é validado. |
phone | string | condicional | E.164 (+5511999999999). Não é único — ver "Identidade" acima. |
name | string | não | Nome completo, um campo só. |
taxId | string | não | CPF ou CNPJ. Pontuação é aceita e removida: o contato guarda só os dígitos. CPF é validado pelo dígito verificador, CNPJ pelos 14 dígitos; inválido responde 400. |
externalId | string | não | O id do contato no seu sistema. |
externalStudentId | string | não | O id de aluno no seu sistema, quando for diferente do externalId. |
birthDate | string | não | AAAA-MM-DD (1998-04-12), uma data real e não futura. Na resposta volta como timestamp ISO (1998-04-12T00:00:00.000Z). |
gender | enum | não | male, female, other. |
preferredLanguage | string | não | Tag BCP 47 (pt-BR). |
| (customizadas) | — | não | Pela chave da propriedade — interesse_em_bolsa. |
Resposta 201:
{
"id": "clz9q2k8r0001s6vp2h4m7x1e",
"properties": {
"name": "Ana Souza",
"email": "ana.souza@exemplo.com",
"phone": "+5511999999999",
"taxId": "12345678901",
"externalId": "aluno-4471",
"interesse_em_bolsa": "Sim"
},
"createdAt": "2026-09-17T13:02:11.482Z",
"updatedAt": "2026-09-17T13:02:11.482Z"
}
A resposta traz só as propriedades preenchidas. Propriedade sem valor não vem como
null — ela simplesmente não aparece.
Erros
| HTTP | errorCode | Quando |
|---|---|---|
400 | — | Corpo malformado, nem email nem phone, ou email, taxId ou birthDate inválido. |
409 | CONTACT_IDENTITY_CONFLICT | Já existe contato com esse email, taxId ou externalId na instituição. details.property diz qual. Use o lote se a intenção era sincronizar. |
422 | CONTACT_PROPERTY_KEYS_UNKNOWN | Propriedade customizada inexistente no catálogo. details.keys lista as chaves. |
422 | PROPERTY_VALUE_NOT_IN_OPTIONS | Valor fora das opções de um SELECT ou MULTI_SELECT. |
422 | PROPERTY_INVALID_VALUE | Valor que não serve para o tipo da propriedade. details.reason diz o motivo. |
Os erros do gate (401, 403) e o formato de cada resposta estão em
Erros.
Atualizar um contato
PATCH /contacts/{contactId} · síncrono
curl -X PATCH "API_BASE/public/v1/crm/contacts/clz9q2k8r0001s6vp2h4m7x1e" \
-H "Authorization: Bearer wf_sua_chave_aqui" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"phone": "+5511988888888",
"interesse_em_bolsa": "Não"
}
}'
É merge, não substituição. Só as chaves enviadas mudam; as demais ficam como estavam.
Não existe forma de "zerar o contato" mandando um properties menor.
Para limpar uma propriedade, mande null:
{ "properties": { "interesse_em_bolsa": null } }
Mandar "" grava a string vazia, que é diferente de vazio. A distinção é de propósito:
uma integração que envia "" por engano não apaga dado sem deixar rastro.
Exceção: taxId. Aqui "" limpa o documento, igual a null. Para preservar o
taxId, omita a chave do corpo; envie "" ou null só quando quiser apagá-lo.
Resposta 200 — o contato inteiro, no mesmo formato do POST.
Erros
| HTTP | errorCode | Quando |
|---|---|---|
400 | — | Corpo malformado, ou email, taxId ou birthDate inválido. |
404 | PROPERTY_CONTACT_NOT_FOUND | O contato não existe, ou é de outra instituição. |
409 | CONTACT_EXTERNAL_ID_MISMATCH | O contato já tem outro externalId gravado, diferente do enviado. |
422 | CONTACT_PROPERTY_KEYS_UNKNOWN, PROPERTY_VALUE_NOT_IN_OPTIONS, PROPERTY_INVALID_VALUE | Os mesmos do POST. |
Endereçar sem saber o id
Use idProperty para endereçar o contato por uma das propriedades de identidade:
curl -X PATCH "API_BASE/public/v1/crm/contacts/ana.souza@exemplo.com?idProperty=email" \
-H "Authorization: Bearer wf_sua_chave_aqui" \
-H "Content-Type: application/json" \
-d '{ "properties": { "phone": "+5511988888888" } }'
idProperty aceita externalId, email ou taxId. Escape o valor na URL — um e-mail
com + quebra se não for escapado.
Sem idProperty, o segmento da URL é sempre o id do Workfuse.
Ler um contato
GET /contacts/{contactId} · síncrono
curl "API_BASE/public/v1/crm/contacts/clz9q2k8r0001s6vp2h4m7x1e" \
-H "Authorization: Bearer wf_sua_chave_aqui"
Aceita o mesmo ?idProperty= do PATCH. Responde 404 (PROPERTY_CONTACT_NOT_FOUND)
para contato de outra instituição — do seu lado é indistinguível de inexistente, e é de
propósito.
Com consentimentos (include=consents)
Por padrão a resposta não traz consentimentos. Para recebê-los junto do contato:
curl "API_BASE/public/v1/crm/contacts/clz9q2k8r0001s6vp2h4m7x1e?include=consents" \
-H "Authorization: Bearer wf_sua_chave_aqui"
A resposta é a de sempre, com a chave consents a mais:
{
"id": "clz9q2k8r0001s6vp2h4m7x1e",
"properties": { "name": "Ana Souza", "email": "ana@exemplo.com" },
"consents": [
{
"channel": "EMAIL",
"status": "GIVEN",
"sendable": true,
"address": "ana@exemplo.com",
"termsVersion": "v2",
"origin": { "id": "clo1a2b3c4d5e6f7g8h9i0j1k", "name": "Portal do aluno" },
"consentedAt": "2026-09-02T10:00:00.000Z"
},
{ "channel": "PUSH", "status": "NONE", "sendable": false },
{ "channel": "WHATSAPP", "status": "NONE", "sendable": false }
],
"createdAt": "2026-09-01T12:00:00.000Z",
"updatedAt": "2026-09-02T10:00:00.000Z"
}
É um item por canal, sempre os três (EMAIL, WHATSAPP, PUSH) — localize pelo
channel, não pela posição.
| Campo | Descrição |
|---|---|
channel | EMAIL, WHATSAPP ou PUSH. |
status | Estado do canal — veja a tabela abaixo. |
sendable | true quando dá para enviar por este canal. Em EMAIL, responde pelo e-mail atual do contato, com a mesma regra da campanha: entre o último evento desse endereço e o último evento do canal sem endereço, vale o mais novo; evento de outro endereço não conta. Em WHATSAPP e PUSH, pelo último evento do canal. |
address | O endereço consentido (por exemplo, o e-mail). Só em GIVEN e REVOKED. |
termsVersion | Versão dos termos aceitos. Só em GIVEN e REVOKED. |
origin | { id, name } da origem do consentimento. Só em GIVEN e REVOKED. |
consentedAt | Data do evento que decide o canal. Só em GIVEN e REVOKED. |
Os campos status, address, termsVersion, origin e consentedAt vêm do evento que decide
sendable — em EMAIL, o escolhido pela regra acima.
status | Significa |
|---|---|
GIVEN | O evento que decide o canal é um consentimento. |
REVOKED | O evento que decide o canal é uma revogação. |
NONE | Nenhum evento decide o canal: nunca houve consentimento nele ou, em EMAIL, só há evento de outro endereço. O item traz só channel, status e sendable. |
Um include que a rota não conhece responde 400, com a lista aceita em details.accepted.
A convenção vale para toda a API: veja Referência da API.
Histórico de consentimentos
GET /contacts/{contactId}/consents · síncrono
curl "API_BASE/public/v1/crm/contacts/clz9q2k8r0001s6vp2h4m7x1e/consents" \
-H "Authorization: Bearer wf_sua_chave_aqui"
Aceita o mesmo ?idProperty= do PATCH. Os eventos vêm do mais recente ao mais antigo,
em data, sem paginação:
{
"data": [
{
"id": "clq8w2e4t0002s6vp9k1n3b7d",
"channel": "EMAIL",
"eventType": "REVOKED",
"address": "ana@exemplo.com",
"termsVersion": null,
"termsText": null,
"origin": { "id": "clo1a2b3c4d5e6f7g8h9i0j1k", "name": "Portal do aluno" },
"detail": "Descadastro no portal do aluno",
"consentedAt": "2026-09-05T09:00:00.000Z",
"createdAt": "2026-09-05T09:00:03.000Z"
},
{
"id": "clq7r1d3s0001s6vp4j8m2c5a",
"channel": "EMAIL",
"eventType": "GIVEN",
"address": "ana@exemplo.com",
"termsVersion": "v2",
"termsText": "Aceito receber comunicações da instituição.",
"origin": { "id": "clo1a2b3c4d5e6f7g8h9i0j1k", "name": "Portal do aluno" },
"detail": null,
"consentedAt": "2026-09-02T10:00:00.000Z",
"createdAt": "2026-09-02T10:00:04.000Z"
}
]
}
termsText é o texto exato que a pessoa aceitou, guardado no momento do consentimento.
IP, user agent e quem registrou não saem pela API. Contato de outra instituição responde
404.
Registrar consentimento
POST /contacts/{contactId}/consents · síncrono
Grava um consentimento ou uma revogação feita fora do Workfuse. Exige as chaves crm e
crm.consents no escopo da API key; marque as duas ao criar a chave na plataforma.
Aceita o mesmo ?idProperty= do PATCH.
| Campo | Obrigatório | O que é |
|---|---|---|
eventType | sim | GIVEN (aceite) ou REVOKED (revogação). |
channel | sim | EMAIL, PUSH ou WHATSAPP. |
address | não | O endereço do canal. Em EMAIL, um e-mail válido; em WHATSAPP, telefone com DDI e DDD (10 a 15 dígitos, + opcional); em PUSH, o token, texto livre. Fora disso, 400. |
originId | sim | A origem do consentimento. Pegue o id em GET /consent-origins. |
formDate | sim | Quando a pessoa aceitou ou revogou, em ISO 8601. Data no futuro (com 5 minutos de tolerância de relógio) dá 400. |
termsVersion | em GIVEN | A versão do termo aceito. Em REVOKED é ignorada. |
detail | não | Detalhe livre, como "Descadastro por e-mail". |
curl -X POST "API_BASE/public/v1/crm/contacts/clz9q2k8r0001s6vp2h4m7x1e/consents" \
-H "Authorization: Bearer wf_sua_chave_aqui" \
-H "Content-Type: application/json" \
-d '{
"eventType": "REVOKED",
"channel": "EMAIL",
"address": "ana@exemplo.com",
"originId": "clo1a2b3c4d5e6f7g8h9i0j1k",
"formDate": "2026-09-05T09:00:00.000Z",
"detail": "Descadastro por e-mail"
}'
A resposta é o mesmo item do histórico:
{
"id": "clq8w2e4t0002s6vp9k1n3b7d",
"channel": "EMAIL",
"eventType": "REVOKED",
"address": "ana@exemplo.com",
"termsVersion": null,
"termsText": null,
"origin": { "id": "clo1a2b3c4d5e6f7g8h9i0j1k", "name": "Portal do aluno" },
"detail": "Descadastro por e-mail",
"consentedAt": "2026-09-05T09:00:00.000Z",
"createdAt": "2026-09-05T09:00:03.000Z"
}
201— o evento foi criado.200— o último evento do canal já tinha o mesmoeventType,termsVersioneaddress. A resposta é esse evento, e o histórico não ganha uma linha nova. Dá para repetir a chamada sem medo, inclusive em paralelo (um reenvio depois de timeout): só uma chamada grava.originId,formDateedetailda repetição não são comparados nem atualizados — vale o evento que já estava lá. Para registrar outra origem ou data, mude o estado (por exemplo,REVOKEDe depoisGIVEN).
A instituição vem do contato. O evento registra a sua API key como autora; o histórico não expõe isso.
Erros
| HTTP | Quando |
|---|---|
403 | A chave não tem crm ou crm.consents no escopo (MODULE_NOT_IN_KEY_SCOPE). |
403 | A organização não tem a capability crm.consents habilitada (CAPABILITY_NOT_ENABLED, com "code": "crm.consents"). |
403 | Conta de serviço cujo papel não tem integration.public-crm.use ou contact.contacts.consent (PERMISSION_DENIED). |
404 | Contato não encontrado, ou de outra instituição. |
422 | GIVEN sem termsVersion (CONSENT_PROOF_INCOMPLETE), ou origem inexistente, inativa ou de outra instituição (CONSENT_ORIGIN_UNAVAILABLE). |
Sincronizar em lote (upsert)
POST /contacts/batch/upsert · assíncrono
É o endpoint da sincronização periódica: existe, atualiza; não existe, cria. Evita o
409 do POST e a ida extra para descobrir o id.
curl -X POST "API_BASE/public/v1/crm/contacts/batch/upsert" \
-H "Authorization: Bearer wf_sua_chave_aqui" \
-H "Content-Type: application/json" \
-d '{
"inputs": [
{
"id": "aluno-4471",
"idProperty": "externalId",
"properties": { "name": "Ana Souza", "interesse_em_bolsa": "Sim" }
},
{
"id": "bruno.lima@exemplo.com",
"idProperty": "email",
"properties": { "phone": "+5511977777777" }
}
]
}'
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
inputs | array | sim | De 1 a 1.000 itens por chamada. |
inputs[].id | string | sim | O valor da propriedade de identidade. |
inputs[].idProperty | enum | sim | externalId, email ou taxId. |
inputs[].properties | object | sim | As propriedades a gravar — merge, igual ao PATCH. |
Resposta 202 — o lote entrou na fila:
{
"batchId": "clzb4t7x90000s6vp8k2m1q3a",
"accepted": 2,
"receivedAt": "2026-09-17T13:02:11.482Z"
}
O que o 202 quer dizer
Recebi e vou processar, não pronto. Três consequências que mudam como você integra:
- Nenhum contato foi criado ainda quando você recebe essa resposta. Não encadeie uma chamada que dependa disso.
- Para consultar o resultado, use
GET /batches/{batchId}— retorna o progresso e o resultado por item (sucesso, erro de validação, conflito de identidade). - Para ser notificado contato a contato, assine
contact.createdecontact.updated. Item que falha ou que não muda nada não dispara.
O prazo, os limites e a política de retentativa estão em
Limites e cotas. O resumo:
até 5 minutos em condição normal, entrega at-least-once, e reenviar o mesmo lote é
seguro e não repete webhook para dados iguais — o idProperty é o que torna o reprocessamento inofensivo.
Consultar o lote
GET /batches/{batchId}
O batchId é o que veio no 202. O resultado fica disponível por 30 dias.
curl "API_BASE/public/v1/crm/batches/clzb4t7x90000s6vp8k2m1q3a" \
-H "Authorization: Bearer wf_sua_chave_aqui"
O status é um de três. Enquanto o lote processa, a resposta traz o progresso e nada mais:
{
"batchId": "clzb4t7x90000s6vp8k2m1q3a",
"status": "processing",
"total": 2,
"processed": 1,
"createdAt": "2026-10-06T13:02:11.482Z",
"completedAt": null
}
Quando conclui, vem o resultado de cada item, inclusive dos que falharam. Um item com erro não derruba os outros:
{
"batchId": "clzb4t7x90000s6vp8k2m1q3a",
"status": "completed",
"total": 2,
"processed": 2,
"createdAt": "2026-10-06T13:02:11.482Z",
"completedAt": "2026-10-06T13:02:40.100Z",
"results": [
{
"index": 0,
"id": "aluno-4471",
"idProperty": "externalId",
"status": "succeeded",
"operation": "created",
"contactId": "clz9q2k8r0001s6vp2h4m7x1e"
},
{
"index": 1,
"id": "bruno.lima@exemplo.com",
"idProperty": "email",
"status": "failed",
"error": {
"status": 422,
"errorCode": "CONTACT_BATCH_ITEM_INVALID",
"message": "Propriedade inexistente no catálogo da instituição",
"details": { "issues": [] }
}
}
]
}
Campo de results[] | Descrição |
|---|---|
index | A posição do item no inputs que você enviou, começando em 0. |
id, idProperty | O seletor que você enviou, para casar o resultado com o seu registro. |
status | succeeded ou failed. |
operation | created ou updated. Só em succeeded. |
contactId | O id do contato. Só em succeeded. |
error | status (HTTP do item), errorCode, message e, quando houver, details. Só em failed. |
Se o lote inteiro falha (erro nosso, não de um item), o status é failed, sem results.
Reenvie o lote: o idProperty torna o reenvio inofensivo.
| HTTP | errorCode | Quando |
|---|---|---|
403 | — | Módulo crm fora do escopo da API key (MODULE_NOT_IN_KEY_SCOPE) ou capability não habilitada (CAPABILITY_NOT_ENABLED). |
404 | CONTACT_BATCH_NOT_FOUND | batchId inexistente, de outra instituição ou com mais de 30 dias — do seu lado é indistinguível, e é de propósito. |
Erros
accepted conta os itens que passaram na validação de forma. Um item malformado reprova a
chamada inteira com 400 — nada entra na fila:
| HTTP | errorCode | Quando |
|---|---|---|
400 | — | Algum item sem id, sem idProperty, com corpo malformado, nenhum item ou mais de 1.000. |
403 | — | Módulo crm fora do escopo da API key (MODULE_NOT_IN_KEY_SCOPE) ou capability não habilitada (CAPABILITY_NOT_ENABLED). |
Erro de negócio de um item (propriedade inexistente, valor fora das opções, contato sem e-mail nem telefone na criação) fica gravado no resultado do item — consulte o lote.
O que esta API não faz
- Não apaga contato. Exclusão é operação da plataforma, feita no painel — não há
DELETEaqui. - Não lista nem busca contatos.
GET /contactscom filtro está no roadmap; hoje você endereça um contato poridou poridProperty. - Não cria propriedade customizada. O catálogo é da instituição, em Configurações →
Propriedades. Mandar uma chave que não existe lá devolve
422. - Não mescla duplicados. Deduplicação é feita no painel.
- Não cria origem de consentimento. A instituição cadastra na plataforma; a API só lista.
- Não define dono. Contato não tem owner na API pública; só o Negócio aceita
ownerId.
Se você vem do HubSpot
| No HubSpot | Aqui |
|---|---|
POST /crm/v3/objects/contacts | POST /crm/contacts |
PATCH /crm/v3/objects/contacts/{id} | PATCH /crm/contacts/{contactId} |
POST /crm/v3/objects/contacts/batch/upsert — síncrono, 100 itens | POST /crm/contacts/batch/upsert — assíncrono, 1.000 itens |
?idProperty=email | Igual — mas também aceita externalId e taxId |
firstname + lastname | name, um campo só |
associations[] na criação | O vínculo nasce no Negócio: contact no POST /deals |
archived: false na resposta | Não existe: o Contato não tem arquivamento |
Medido em developers.hubspot.com em 17/09/2026.