Pular para o conteúdo principal

API Construflow — documentação de integração

Este documento é autossuficiente: ele leva você da emissão da credencial até uma cópia sincronizada dos dados de obra, passando pelos limites de uso, pelos filtros de consulta, pela escrita e pela importação de verificação de modelo. Tudo o que a sua integração precisa saber está aqui.


1. Abertura

O que a API entrega

O Construflow é uma plataforma de gestão de obras. O objeto central é o apontamento: um problema, uma tarefa ou uma verificação registrada num projeto, com disciplinas responsáveis, prazo, situação, prioridade, local, etiquetas, imagens, posição no modelo 3D e uma conversa de comentários.

A API expõe esse acervo para leitura e escrita:

O que você consegue fazerOnde
Descobrir quais projetos a sua credencial enxerga§6
Ler o cadastro de cada projeto — disciplinas, situações de disciplina, locais, etiquetas, categorias, fases§6
Manter uma cópia sincronizada de apontamentos e comentários, inclusive exclusões§7
Consultar com o vocabulário completo de filtros§10
Criar e alterar apontamentos e comentários, anexar imagens§11
Importar uma verificação de modelo em BCF§12

Os casos de uso que essa superfície atende bem: espelhar o acervo de apontamentos num ERP ou num painel de BI; alimentar um agente de IA que acompanha a obra; publicar de volta o que o seu sistema decidiu; e levar o resultado de uma verificação de modelo BIM para dentro do projeto.

O que ela não atende: notificação em tempo real. Não há canal de assinatura aberto a integradores — o caminho é a sincronização incremental de §7.

Como a API é servida

  • Uma superfície principal, GraphQL: POST /graphql. Uma operação por chamada HTTP. É por onde passa todo o acervo — projetos, apontamentos, comentários — e toda a escrita.
  • Duas rotas REST, cada uma para o que não cabe no grafo: GET /identity/users/me, o cadastro de quem assina a credencial (§2), e POST /bcf-server/bcf/import-process/{projectId}, a importação de verificação de modelo (§12).
  • Binário nunca trafega pela API: imagens sobem por URL assinada, direto no armazenamento (§11).

Vocabulário

Este texto usa os termos de obra; a API usa os nomes em inglês. O mapa:

Neste documentoNa API
apontamentoissue
comentáriocomment
disciplinadiscipline
locallocal
etiquetalabel
categoriacategory
fasephase
situaçãostatus
visibilidadevisibility

Quanto custa integrar

Uma integração de espelhamento completa são três consultas, executadas em laço por projeto, mais uma consulta de descoberta (§6) e o cadastro de cada projeto (§6), relido de tempos em tempos e não a cada rodada. Escrever as chamadas é a parte barata.

O que consome o tempo de quem integra é tratar corretamente cinco coisas, e todas estão documentadas aqui:

  1. Paginação e cursor — inclusive um falso positivo na sinalização de próxima página (§10).
  2. A janela de data — as bordas são inclusivas e o início errado perde edições (§7).
  3. Exclusões — duas fontes independentes, e um conjunto de mudanças que não produz sinal nenhum (§9).
  4. Os limites de uso e o contrato de recusa (§5).
  5. As regras da alteração parcial na escrita, onde lista vazia apaga em uns campos e é recusada em outros (§11).

Leia §4, §5, §7 e §9 antes de decidir a arquitetura da sua rotina. Os três últimos mudam o desenho, não o detalhe.

Índice


2. Autenticação

A API usa HTTP Basic Auth com uma Chave de Acesso. Toda requisição leva o cabeçalho:

Authorization: Basic base64(<chave>:<segredo>)

Emitir a chave e o segredo

Você emite a sua credencial sozinho, pela interface do Construflow. Não há aprovação, papel especial nem módulo contratado envolvido:

  1. Clique no avatar do usuário, no canto da tela, e escolha Configurações.
  2. No menu lateral, escolha Chaves de acesso.
  3. Selecione Criar para gerar a chave e o segredo.
  4. Copie os dois. O segredo é exibido uma única vez, no momento da criação — depois não há como recuperá-lo, só emitir outra chave.

⚠️ Conta demonstrativa não emite chave. A criação é recusada para o usuário de demonstração. Para integrar é preciso um usuário comum da plataforma.

Teste imediatamente, na rota que responde de quem é a credencial:

curl https://api.construflow.com.br/identity/users/me \
-u "$CONSTRUFLOW_KEY:$CONSTRUFLOW_SECRET" \
-H "User-Agent: MinhaIntegracao/1.0 (contato@empresa.com)"
{
"id": 3367,
"guid": "9f1c…",
"name": "Integração",
"lastName": "Empresa",
"email": "integracao@empresa.com.br",
"photo": null,
"isAdmin": false,
"isBeta": false,
"isDemo": false,
"acceptedPolicies": true,
"confirmedRegister": "2025-04-02T13:20:11.000Z",
"belongsToAccount": true
}

Se o cadastro veio, está autenticado. Se a resposta for 401, leia Quando a credencial não é aceita.

Esta é a chamada mais barata da API — ela não toca em projeto nenhum —, e é a certa para o healthcheck da sua integração e para a tela de configuração dela ("chave conectada como Integração Empresa"). Ela responde quem é a credencial; o que ela alcança vem da lista de projetos (§6).

Todas as operações deste documento assumem que a requisição já leva o cabeçalho de autenticação.

O que a credencial enxerga

A chave é o usuário que a emitiu. Ela não tem escopo próprio:

CaracterísticaComportamento
AcessoExatamente o do usuário dono: os projetos dele, a visibilidade dele, as permissões dele em cada projeto
Restrição por projetoNão existe. Não há como emitir uma chave limitada a um subconjunto de projetos
Restrição a somente-leituraNão existe. A mesma chave lê e escreve
ValidadeNão expira. Não há data de vencimento nem rotação automática
RevogaçãoManual, na mesma tela em que a chave foi criada
QuantidadeSem limite de chaves por usuário

A consequência prática de "a chave é o usuário": emita a credencial a partir de um usuário criado para a integração, com as permissões de projeto que a integração precisa e nada além. Uma chave emitida por um coordenador enxerga tudo que aquele coordenador enxerga, inclusive apontamentos restritos que a integração não deveria receber.

As permissões relevantes, por projeto:

ParaÉ preciso ter no projeto
Ler apontamentos, comentários e cadastropermissão de visualizar
Criar/alterar apontamento e comentário, pedir URL de uploadpermissão de participar
Alterar o cadastro do projetocoordenação ou administração

⚠️ A revogação não é instantânea. Uma chave revogada pode continuar sendo aceita por até 10 minutos. Se você precisa cortar o acesso agora — vazamento de segredo, desligamento de um fornecedor — conte com essa janela e trate o que acontecer nela.

⚠️ A cota de uso é por identidade, não por chave. Criar uma segunda Chave de Acesso para o mesmo usuário não dobra nada: as duas somam no mesmo balde (§5). Para separar cotas é preciso separar usuários.

A identidade é derivada da credencial pelo servidor. Não existe parâmetro de usuário em nenhuma operação, e cabeçalhos de identidade enviados por você são descartados na entrada — não há como uma integração agir em nome de outra pessoa.

Quando a credencial não é aceita

Nas rotas REST, a recusa é direta: 401, com WWW-Authenticate: Bearer e o corpo

{ "code": "UNAUTHENTICATED", "message": "User not authenticated" }

No POST /graphql, não. A requisição segue, a identidade simplesmente não é resolvida, e a operação falha na autorização:

{
"errors": [
{
"message": "User not authenticated",
"extensions": { "code": "UNAUTHENTICATED" }
}
]
}

⚠️ O status dessa resposta é 200, não 401 — a porta do GraphQL admite operações legitimamente anônimas, então ela não recusa por falta de identidade. É por isso que o teste de credencial acima usa a rota REST: lá o desfecho está no status.

Em qualquer das duas, chave revogada, segredo errado e cabeçalho ausente produzem o mesmo desfecho — não há como distinguir os três pela resposta. Se a sua integração parou de autenticar, verifique nesta ordem: o cabeçalho está sendo enviado; o base64 está correto; a chave não foi revogada.


3. Endereço e rotas

O host é https://api.construflow.com.br, e sobre ele existem duas rotas:

RotaPara quê
POST /graphqlTodas as consultas e alterações
GET /identity/users/meO cadastro de quem assina a credencial (§2)
POST /bcf-server/bcf/import-process/{projectId}Abrir importação de BCF (§12)

O esquema GraphQL não é navegável. Introspecção (__schema, __type) é recusada. Este documento é a referência. __typename continua funcionando normalmente.


4. Erros e recusas

Antes de escrever a primeira consulta, entenda como uma recusa chega até você. Integração que confunde as famílias abaixo faz retry do que nunca vai mudar e desiste do que só precisava esperar.

As três formas de recusa

É o status HTTP que as separa. Valem para o POST /graphql, por onde passa quase tudo; as rotas REST recusam credencial com 401 (§2):

SituaçãoStatusOnde está o detalhe
Erro de negócio, de permissão, de autenticação ou de formato de filtro200errors[0].extensions.code e errors[0].message
Consulta acima de um teto de forma/custo, ou array de operações400errors[0].extensions
Excesso de taxa, em qualquer balde429cabeçalhos RateLimit* + corpo

⚠️ 200 não significa sucesso. Erro de permissão, de autenticação e de formato de filtro chegam com 200 e a lista errors preenchida — é o comportamento normal do GraphQL, e é a causa mais comum de integração que grava dados vazios sem perceber. Sempre verifique errors antes de ler data.

⚠️ Recusa 400 não tem prazo de espera. Ela não traz Retry-After nem retryAfter, e isso é deliberado: repetir a mesma consulta produz a mesma recusa para sempre. Corrija a consulta — não faça retry.

Os códigos de 400 são quatro, e todos são sobre a forma da chamada, nunca sobre o conteúdo de um filtro:

extensions.codeO que aconteceuextensions trazOnde está explicado
QUERY_DEPTH_EXCEEDEDA consulta passou de 10 níveis de profundidadecode, reason, limit, observed§5
PAGE_SIZE_EXCEEDEDfirst acima de 200code, reason, limit, observed§5
QUERY_COST_EXCEEDEDA consulta instancia objetos demaiscode, reason, limit, observed§5
BATCHING_NOT_SUPPORTEDVocê enviou um array de operações numa chamada sócode§5

A ordem de avaliação é fixa — profundidade, depois tamanho de página, depois preço. Uma consulta que viola dois tetos é sempre recusada pelo primeiro.

⚠️ As recusas por formato de filtro NÃO são 400. Cursor de paginação fora de formato (INVALID_PAGINATION_CURSOR) e filter.ids com item inválido (INVALID_ISSUES_IDS_FILTER) chegam com status 200, o campo consultado nulo e o erro em errors[0]. É contraintuitivo — parecem requisição malformada, e não são tratadas como tal. Detalhe em §10.

A quarta forma: recusa na borda

⚠️ Corpo grande demais não devolve 429 nem 413 — devolve 403. O teto de 2 MB por requisição é aplicado na borda da rede, antes de a requisição chegar à aplicação. A resposta não é GraphQL: não tem errors, não tem extensions, não tem cabeçalho de limite. Isso contradiz a intuição e é a recusa mais difícil de diagnosticar — se você recebeu um 403 seco onde esperava um erro de API, meça o tamanho do corpo que enviou.

Binário não deve ir embutido na requisição. Use a URL assinada (§11) e faça o PUT direto no armazenamento.

O que pode ser retentado

StatusRetry?Como
200 com errorsNãoÉ erro de negócio, permissão ou autenticação. Corrija a chamada ou a permissão
400NuncaA resposta não muda. Corrija a consulta
403NãoReduza o corpo
429SimEspere exatamente o que o Retry-After mandar (§5)
5xxSimBackoff exponencial 2^n segundos, com jitter. Nunca em laço imediato

5. Limites de uso

A política é não bloquear. Não existe suspensão automática, bloqueio automático nem revogação automática de chave. Exceder um limite recusa aquela requisição, e nada mais: a requisição seguinte, dentro da faixa, passa normalmente. Se um padrão de uso passa a degradar a plataforma, nós procuramos o responsável pela conta; a revogação de uma Chave de Acesso, se chegar a esse ponto, é sempre uma ação manual tomada depois desse contato.

Isso não faz dos limites uma sugestão. A capacidade é compartilhada entre todos os clientes, e uma integração que trabalha acima da faixa produz falha primeiro para si mesma: a maior parte das recusas cai sobre quem está causando a rajada, na forma de rodadas incompletas e dados faltando na sua base — descobertos dias depois, quando alguém nota o buraco. Dimensionar dentro da faixa é, antes de tudo, o que torna a sua integração previsível.

A tabela de limites

LimiteValorAo exceder
Volume total por identidade (portaria: toda requisição que passa pelo gateway)160 / 10 s e 420 / 60 s429 user-envelope
Consultas por identidade120 / 10 s e 300 / 60 s429 user-read
Mutations por identidade40 / 10 s e 120 / 60 s429 user-write
Emissão de URL assinada por identidade (balde próprio)40 / 10 s e 120 / 60 s429 user-upload-url
Requisições simultâneas por identidade8 em voo429 credential-concurrency
Requisições por endereço de origem6.000 / 60 s429
Operações por chamada HTTP1400 BATCHING_NOT_SUPPORTED
Profundidade da consulta10 níveis400 QUERY_DEPTH_EXCEEDED
Tamanho de página (first)200400 PAGE_SIZE_EXCEEDED
Preço da operaçãopor objeto instanciado — ver abaixo400 QUERY_COST_EXCEEDED
Corpo em application/json2 MB403 na borda
Tempo de cada consulta ao banco30 s200 com erro genérico — ver abaixo

⚠️ Os 30 s são por consulta ao banco, não por operação. Uma operação que dispare muitas consultas curtas pode levar bem mais de 30 s no total sem que nenhuma seja cortada; o que estoura é a consulta individual que passa disso. Quando estoura, você recebe 200 com a lista errors preenchida, um erro genérico e sem código dedicado — não há code que identifique "tempo esgotado". A pista é a mensagem, não o código. Do lado do cliente, o remédio é reduzir a página e a seleção, não fazer retry.

Não há limite diário. As janelas são de 10 e de 60 segundos. Não existe cota mensal, nem número máximo de requisições por dia — o que existe é ritmo.

Como os limites se combinam

As duas janelas coexistem. A de 10 segundos (rajada) e a de 60 segundos (sustentado) valem ao mesmo tempo, e basta uma estourar para a requisição ser recusada. A janela curta existe porque a longa, sozinha, admitiria a cota inteira nos dois primeiros segundos do minuto — que é exatamente a forma de tráfego que derruba o serviço.

A portaria recusa primeiro — e o motivo não é o número, é o que ela conta. Os números da portaria empatam com a soma dos baldes finos (120+40 = 160, 300+120 = 420), então parece que ela nunca chegaria antes. Chega, porque ela conta toda requisição autenticada que atravessa o gateway, sem distinguir método, rota nem tipo de operação: consultas, alterações, emissão de URL assinada, importação de BCF e qualquer chamada a outro serviço da plataforma. Os baldes finos orçam só as operações GraphQL de leitura e de escrita; a portaria orça o seu tráfego inteiro.

O exemplo mais direto: 120 consultas + 40 alterações + 40 emissões de URL assinada em 10 segundos. Nenhum balde fino estourou — cada um está exatamente no teto — e a portaria recusa na 161ª requisição. Quando isso acontece, a razão devolvida é user-envelope, não user-read nem user-write, e ela vem com o corpo plano (o envelope de recusa).

Ao dimensionar, conte todas as suas requisições contra 160/10 s e 420/60 s — não só as que você classificaria como consulta ou como escrita.

A cota é por identidade. Não por chave (duas chaves do mesmo usuário somam no mesmo balde), não por empresa, e não por endereço de origem. Distribuir a carga entre várias máquinas não aumenta o que a credencial pode fazer — o limite por IP é uma proteção adicional, não uma cota que se multiplica.

Emissão de URL assinada tem balde próprio. Ela não consome o balde de mutations, mas consome a portaria. Emitir a URL é barato — o binário não passa pela API. Peça as URLs do gesto inteiro numa chamada só (até 50 nomes), sob demanda: N chamadas paralelas é o que estoura este balde.

Forma e custo da consulta

Uma operação por chamada HTTP. Enviar um array de operações num único POST /graphql responde 400 com BATCHING_NOT_SUPPORTED e nada é executado — inclusive quando o array está vazio. Se o seu cliente GraphQL agrupa automaticamente (BatchHttpLink e equivalentes), desligue isso. Não confunda com as mutations em lote (createIssues, updateIssues, copyProjectIssues), que continuam sendo a forma recomendada de escrever muitos apontamentos: elas são uma operação com muitos itens (§11).

Profundidade máxima de 10 níveis. Cada nível de seleção conta, incluindo o nível do campo escalar final. Em project → issues → issues → userPersonalData → tags → tag → id a profundidade é 7. Fragmentos são medidos onde foram espalhados e não acrescentam nível por si. A recusa acontece antes de qualquer acesso ao banco, então uma consulta profunda demais é barata para nós e inútil para você.

Página máxima de 200. Passe first sempre de forma explícita, em toda consulta paginada — e é justamente a omissão que o teto não protege. Quando você não informa o tamanho da página, a consulta é avaliada como se fosse 100, passa folgada pelo teto de 200 — e o servidor devolve até 500, que é o padrão real das duas consultas paginadas. Você recebe cinco vezes a página que a avaliação considerou, sem nenhum sinal. Uma página de 500 itens é mais cara e mais lenta do que qualquer página que você conseguiria pedir de propósito, e é por isso que informar o valor é obrigatório, não recomendado.

Preço da operação. O custo é cobrado por objeto instanciado, não por campo pedido:

  • Campo escalar folha (id, title, updatedAt, createdByUserId) custa zero.
  • O que multiplica é o tamanho da página vezes o número de objetos por item. Cada disciplines { discipline { ... } }, locals { local { ... } }, images { ... }, createdByUser { ... } é um objeto por item.
  • Uma listagem de apontamentos com os relacionamentos expandidos custa da ordem de 15 objetos por apontamento. Com first: 50, são 750 objetos numa chamada — dentro da faixa. O mesmo pedido com first: 200 seria recusado com QUERY_COST_EXCEEDED.

⚠️ A regra prática de tamanho de página, e ela é a que evita a recusa:

A sua seleçãofirst
Tem relacionamentos expandidos (disciplines { discipline { … } }, locals { local { … } }, labels { … }, images { … }, createdByUser { … })da ordem de 50
Só campos escalares (id, code, title, status, updatedAt, createdByUserId, deletedAt…)até 200

Peça menos campos e ajuste a página ao que pediu. Numa varredura, trocar createdByUser { name } por createdByUserId elimina um objeto por item — o nome vem depois, aninhado, na consulta de detalhe, que traz um apontamento por vez (§8).

O envelope de recusa por taxa

Toda recusa por taxa traz o status 429 e os mesmos sete cabeçalhos. O corpo, porém, vem em duas formas. A mais comum é o envelope GraphQL:

HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Policy: "burst";q=120;w=10, "sustained";q=300;w=60
RateLimit: "burst";r=0;t=3, "sustained";r=0;t=12
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785000060
X-RateLimit-Reason: user-read
Content-Type: application/json; charset=utf-8
{
"errors": [
{
"message": "You have reached the request rate limit",
"extensions": {
"code": "REQUEST_RATE_EXCEEDED",
"reason": "user-read",
"retryAfter": 12,
"policy": "\"burst\";q=120;w=10, \"sustained\";q=300;w=60"
}
}
]
}

Como ler:

  • Retry-After é em segundos e tem precedência sobre tudo o mais. Se você só for tratar um campo, trate este. Não invente o seu próprio intervalo a partir das janelas anunciadas: quem sabe quanto falta é o servidor.
  • RateLimit-Policy anuncia as políticas simultâneas do balde que recusou — q = cota, w = janela em segundos. RateLimit dá o estado atual das mesmas políticas — r = restante, t = segundos até a virada.
  • X-RateLimit-Limit / -Remaining / -Reset descrevem a política que efetivamente recusou. ⚠️ -Reset é época Unix em segundos — base de tempo diferente do t= do RateLimit, que é um delta. Não são o mesmo número escrito de dois jeitos, e somar um ao relógio local dá um instante no ano de 1970 ou daqui a 56 anos, conforme o que você confundir.
  • X-RateLimit-Reason diz qual balde estourou. É o que permite ajustar o lado certo sem adivinhação: recusa por user-write não se resolve reduzindo leitura.
  • Os mesmos campos vêm no corpo (reason, retryAfter, policy) porque a leitura de cabeçalho depende de CORS: numa integração de servidor o corpo sempre chega. ⏳ Escopado à desbur/main — NÃO vale na main ainda: a API não aceita chamada feita pelo navegador a partir de um site que não seja o aplicativo do Construflow. Uma requisição que chega com origem de navegador não autorizada recebe 403 {"error":"origin_not_allowed"} antes de qualquer processamento, e o navegador não expõe nem cabeçalhos nem corpo. Integração roda no servidor, nunca no navegador.

O vocabulário de X-RateLimit-Reason é fechado. Qualquer valor fora desta lista é defeito nosso, não caso novo a tratar:

RazãoO que estourou
user-envelopevolume total da sua identidade, contado na portaria
user-readvolume de consultas
user-writevolume de mutations
user-upload-urlvolume de emissão de URL assinada
credential-concurrencytrabalho simultâneo da sua identidade

⚠️ Nem toda recusa por taxa traz a lista errors. As recusas aplicadas antes de a requisição chegar ao GraphQL vêm com corpo plano, com os mesmos cinco campos na raiz. O status e os sete cabeçalhos são idênticos; só a forma do corpo muda:

{
"message": "You have reached the request rate limit",
"code": "REQUEST_RATE_EXCEEDED",
"reason": "credential-concurrency",
"retryAfter": 1,
"policy": "\"concurrency\";q=8"
}

Qual forma vem em cada caso:

RazãoCorpo
user-envelope (portaria)plano
credential-concurrencyplano
user-read, user-write, user-upload-urlenvelope GraphQL, com errors[0].extensions

Repare que é a portaria — o balde que recusa primeiro — que vem no formato plano. A recusa mais provável de todas é justamente a que não tem errors.

E há um caso em que não vem nada: a recusa por corpo grande demais acontece na borda da rede (§4) e responde 403 sem nenhum dos sete cabeçalhos e sem corpo JSON nosso.

Trate 429 pelo status e pelos cabeçalhos, nunca pela forma do corpo. Um cliente que procura errors[0].extensions.code para decidir se houve recusa por taxa não enxerga as recusas planas e entra em laço.

O teto de simultâneas conta requisições em voo, não por minuto. Ele existe porque saturação chega em rajadas de décimos de segundo, invisíveis a qualquer contagem por minuto. Mantenha no máximo 4 requisições abertas ao mesmo tempo e você nunca o encontra.

Dimensionamento

Dimensione pela taxa, e mantenha o paralelismo baixo. O balde que manda numa rodada de sincronização é o de consultas: 300 por minuto, ou 5 por segundo — a rodada é integralmente de leitura, e a portaria (420/min) nunca chega a ser o gargalo. Um limitador global de ~4,5 requisições por segundo para toda a integração deixa margem para a variação de tempo de resposta e mantém você dentro da faixa. Se a sua integração também escreve, o balde de mutations é separado e menor (120/min) — dimensione a escrita à parte.

Na prática isso são 2 a 4 processos (ou conexões) trabalhando em paralelo, nunca mais: com quatro requisições em voo você já satura a taxa recomendada, bem antes de encostar no teto de simultâneas. Se as respostas ficarem lentas, reduza a taxa — não abra mais conexões.

Paralelismo alto não acelera nada aqui: acima do ritmo do balde, o que você ganha em disparo perde em recusa, e a rajada é justamente o padrão que degrada o tempo de resposta — inclusive o seu.

O resto do bom comportamento cabe em cinco linhas:

  1. Sincronize incrementalmente, não em laço. Não repita a mesma consulta em intervalo curto para "ver se mudou" — use a janela de data (§7). Se precisar de verificação periódica, espace-a em minutos, não em segundos.
  2. Sem fan-out. Não dispare N consultas em paralelo a partir de uma lista recém-recebida.
  3. Peça só os campos que vai usar. É o que forma o preço da operação.
  4. Guarde o cadastro do projeto no seu lado. Disciplinas, categorias, fases, etiquetas e locais mudam raramente: releia-os periodicamente — uma vez por dia é folgado —, não a cada rodada (§6).
  5. Identifique-se. Envie sempre um User-Agent descritivo — MinhaIntegracao/1.2 (contato@empresa.com). É por ele que conseguimos falar com você antes de qualquer revogação, em vez de descobrir um endereço anônimo gerando carga.

6. Descoberta

Duas consultas dão todo o contexto de que a sincronização precisa: a lista de projetos, barata e executada a cada rodada, e o cadastro de cada projeto, relido periodicamente.

Os projetos que a credencial enxerga

Não existe uma query projects de raiz. A lista vem aninhada no usuário logado, e é o ponto de partida de toda integração — não há como listar apontamentos, comentários ou documentos fora de um projeto.

query AccessibleProjects {
loggedUserData {
projects(permission: "view") {
id
guid
name
status
account { id name }
}
}
}

🔴 Passe permission: "view" — o padrão não é o que você quer. Sem o argumento, a lista traz só os projetos em que o usuário tem permissão de participar, isto é, de escrever. Uma credencial provisionada apenas para leitura — que é o que este documento recomenda (§2) — recebe lista vazia, embora leia todos os projetos normalmente por project(projectId). As duas permissões são independentes: ter uma não implica a outra. Como todo papel de projeto concede as duas, view devolve um superconjunto de participate — é sempre o valor certo para descobrir o que se pode ler.

⚠️ O argumento é texto livre, sem validação. O vocabulário é participate, view, manage e invites; qualquer outro valor — inclusive um erro de digitação — devolve lista vazia, sem erro. Se a descoberta voltou vazia, confira a grafia antes de suspeitar de permissão.

⚠️ A lista inclui projeto inativo. O campo status é o que distingue: sincronizar tudo que vem na lista significa varrer projetos encerrados a cada rodada, o que multiplica o custo sem trazer dado novo. Filtre pelo status e decida conscientemente se projeto inativo entra na sua rodada — em geral vale sincronizar uma última vez e depois parar.

⚠️ Projeto de acesso público em que a credencial não é membro não aparece aqui — com nenhum valor de permission. Nesses projetos a permissão de visualizar é concedida no momento da consulta e nunca fica gravada, e esta lista sai do que está gravado. O projeto é perfeitamente consultável por project(projectId) se você souber o identificador — só não há como descobri-lo por aqui, e não há consulta que os enumere.

Projeto inativo não muda nada nas consultas de apontamento: os apontamentos continuam respondendo normalmente.

⚠️ Esta lista é o recorte da sua credencial, e ela encolhe em silêncio. Se o usuário dono perder acesso a um projeto, o projeto some daqui sem nenhum aviso e sem nenhum erro. Guarde o conjunto de identificadores de uma rodada para a outra e compare: projeto que sumiu é o sinal (§9).

O cadastro do projeto

Todo o cadastro vem numa chamada só. Não existem queries dedicadas para essas coleções — são campos aninhados em project(projectId):

query ProjectCatalog($projectId: Int!) {
project(projectId: $projectId) {
id
guid
name

disciplines { id guid name abbreviation }
disciplinesStatus { name active }

locals
labels

categories { id name active }
phases { id key abbreviation name active }

groups { id name }
}
}
ColeçãoO que éFormato
disciplinesDisciplinas do projeto (arquitetura, estrutura, hidráulica…)Tipo forte
localsÁrvore de locais do projeto (torre, pavimento, sala…)JSON livre
labelsÁrvore de etiquetas do projetoJSON livre
categoriesCatálogo global de categorias, anotado com active — ver abaixoTipo forte
phasesCatálogo global de fases, anotado com active — ver abaixoTipo forte
disciplinesStatusCatálogo global de situações de disciplina, anotado com activeTipo forte
groupsGrupos do projeto de que o usuário é membro — ver abaixoTipo forte

Catálogo do projeto × catálogo global

Três dessas coleções — categories, phases e disciplinesStatusnão são listas do projeto. Elas devolvem o catálogo global da plataforma inteiro, sempre com o mesmo número de itens em qualquer projeto, e o que varia de projeto para projeto é a flag active de cada item:

  • Projeto que não customiza o catálogo → todos os itens vêm com active: true.
  • Projeto que customiza → só os escolhidos vêm com active: true; os demais continuam na lista, com active: false.

A lista nunca vem vazia, e "sem restrição" se manifesta como todo mundo ativo, não como ausência de itens. Sempre filtre por active antes de usar um identificador para escrever: escrever com categoria ou fase inativa é recusado (CATEGORY_MUST_BE_ACTIVE / PHASE_MUST_BE_ACTIVE).

O mesmo catálogo global, sem a anotação por projeto, está nas consultas de raiz systemIssueCategories, systemProjectPhases e systemIssueDisciplineStatus — úteis quando você precisa do vocabulário antes de saber de que projeto se trata. Para todo o resto, project { … } já traz o que interessa, e com o active certo.

A forma de locals e labels

locals e labels vêm como JSON livre, e não como tipos fortes — os tipos Local e Label só aparecem aninhados dentro do apontamento (IssueLocal / IssueLabel). Cada um devolve um objeto com duas visões dos mesmos nós:

{
"flatten": [
{ "id": 201, "parentId": null, "name": "Torre A", "abbreviation": "TA",
"abbreviationTree": "TA", "index": 0, "guid": "…", "projectId": 42 },
{ "id": 202, "parentId": 201, "name": "Pavimento 2", "abbreviation": "P02",
"abbreviationTree": "TA/P02", "index": 0, "guid": "…", "projectId": 42 }
],
"tree": [
{ "id": 201, "parentId": null, "name": "Torre A", "abbreviation": "TA",
"abbreviationTree": "TA", "index": 0, "guid": "…", "projectId": 42,
"children": [ { "id": 202, "parentId": 201, "…": "… os mesmos campos …" } ] }
]
}
  • flatten é a lista completa, sem aninhamento, ordenada por id. É a visão que você quer para indexar por identificador e para procurar por abbreviationTree.
  • tree traz só as raízes (parentId: null), com os descendentes aninhados em children — e o nó folha não tem a chave children, ela simplesmente não aparece. A ordem em tree e em cada children é a de exibição (index), não a de id.
  • Os nós de tree são os mesmos objetos de flatten, com os mesmos campos. Além dos mostrados acima, cada nó traz oldId, createdAt, updatedAt e deletedAt — trate-os como ruído.
  • abbreviationTree é o caminho de abreviações da raiz até o nó, separado por /TA/P02/S05. É a chave que a importação de BCF usa para casar locais (§12).
  • Um nó cujo pai foi apagado aparece em flatten e desaparece de tree. Se você depende da árvore para navegação, reconstrua-a a partir de flatten e trate o órfão explicitamente.

Grupos

Um grupo é um subconjunto nomeado de usuários dentro de um projeto — a unidade que a visibilidade group endereça: apontamento ou comentário criado com essa visibilidade é visto pelos membros do grupo indicado, e não pelo projeto inteiro. O identificador vem de project { groups { id name } } e é o que se envia em groupId nas operações de escrita (§11).

⚠️ A lista de grupos é recortada pela credencial: ela devolve apenas os grupos de que o usuário dono é membro. Não há como enumerar os demais grupos do projeto. Para escrever com visibilidade group, ou o usuário da integração pertence ao grupo, ou o identificador tem de vir de fora da API.

Frequência

O cadastro muda raramente. Carregue-o periodicamente — uma vez por dia é folgado — e guarde do seu lado; não o releia a cada rodada. Não use estas coleções para traduzir identificador em nome nos apontamentos: os nomes vêm resolvidos na própria consulta do apontamento, e mais barato (§8). O cadastro serve para outro propósito: preencher um seletor na sua interface e validar um valor antes de escrever (§11).


7. Sincronização incremental

Este é o fluxo recomendado para manter uma cópia dos apontamentos e comentários no seu sistema. Ele roda projeto a projeto, em três passos:

  1. Apontamentos alterados na janela — inclui os excluídos, sob pedido explícito (filter.includeDeleted: true).
  2. Comentários alterados na janela — inclui os excluídos, sempre, sem parâmetro.
  3. Detalhe apenas da união dos dois primeiros.

Os dois primeiros passos dizem o que mudou; o terceiro busca o detalhe só do que mudou. É isso que faz a carga da sua rotina acompanhar a atividade da obra em vez do tamanho do acervo.

Por que são duas fontes, e não uma

⚠️ O passo 2 não é opcional.

Um comentário novo não altera a data de atualização do apontamento a que pertence.

Isso significa que o passo 1 não traz o apontamento que recebeu apenas um comentário. Sem o passo 2, toda conversa em apontamento que ninguém editou fica invisível para você — e comentário é justamente o que mais se move num projeto ativo.

As duas consultas são fontes independentes e ambas obrigatórias. Cada comentário volta com o issueId do apontamento a que pertence: guarde esses identificadores, eles entram na lista do passo 3.

A janela de data

Os dois primeiros passos usam o mesmo filtro de data, com a mesma semântica:

CaracterísticaComportamento
FormatoISO 8601 — "2026-01-15", "2026-01-15T10:00:00-03:00", "2026-01-15T10:00:00Z"
Data pura, sem hora ("2026-01-15")Meia-noite UTC, por especificação
Data e hora sem fuso ("2026-01-15T10:00:00")Interpretada como hora local do processo do servidor — não é UTC
BordasInclusivas nos dois lados
Janela invertida (From > To)Devolve lista vazia, sem erro
Valor inválidoSilenciosamente ignorado — o filtro simplesmente não é aplicado

⚠️ Envie sempre o fuso explícitoZ ou -03:00. É a única forma que significa a mesma coisa dos dois lados. Data e hora sem fuso são resolvidas contra o relógio do processo do servidor, que não é contrato: você não controla esse valor e ele não é o seu. E cuidado com a data pura na borda de cima: updatedAtTo: "2026-01-31" é meia-noite UTC e exclui o dia 31 inteiro.

Duas consequências que mudam o seu código:

Bordas inclusivas significam duplicação, não perda. Janelas adjacentes repetem o item que caiu exatamente na borda. Isso é seguro e é o comportamento desejado — mas exige que a sua gravação seja idempotente: grave por upsert sobre o identificador, nunca por insert.

⚠️ Valor inválido ser ignorado em silêncio é a armadilha mais cara deste filtro. Uma data mal formatada não devolve erro: ela desliga o filtro, e a consulta vira uma varredura total do projeto. A rodada "funciona", demora dez vezes mais e estoura os limites. Se uma rodada incremental começou a trazer o acervo inteiro, o primeiro lugar a olhar é o formato das datas que você mandou.

⚠️ Use o INÍCIO da rodada anterior como início da janela — nunca o fim dela, nem o início da rodada atual. Guarde o instante em que cada rodada começa: ele é o updatedAtTo da rodada corrente e o updatedAtFrom da próxima. Usar o fim da rodada anterior perde tudo que foi alterado enquanto ela estava executando.

janela desta rodada = [ instante em que a rodada ANTERIOR começou , instante em que ESTA rodada começou ]

O motivo é concreto: o filtro de data é reavaliado a cada página, e uma rodada leva minutos. Quem começa a janela no fim da rodada anterior perde tudo que foi alterado enquanto ela executava — e o que é editado durante a rodada entra ou não conforme a página em que estiver, produzindo uma perda que não se repete e que ninguém percebe. Quem usa updatedAtFrom = início desta rodada perde o intervalo inteiro entre as duas rodadas. Com bordas inclusivas e a janela encostada no início da anterior, o pior caso é receber o mesmo item duas vezes — e a sua gravação já é idempotente.

Passo 1 — apontamentos alterados

query UpdatedIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
issues(first: $first, after: $after, filter: $filter) {
pageInfo { endCursor hasNextPage }
issues {
id guid code
title description
status priority visibility
deadline
category creationPhase resolutionPhase
createdAt updatedAt editedAt statusUpdatedAt visibilityUpdatedAt
createdByUserId editedByUserId statusUpdatedByUserId visibilityUpdatedByUserId
deletedAt deletedByUserId
}
}
}
}

Variáveis:

{
"projectId": 42,
"first": 200,
"after": null,
"filter": {
"updatedAtFrom": "2026-01-14T00:00:00Z",
"updatedAtTo": "2026-01-15T00:00:00Z",
"includeDeleted": true
}
}

A seleção acima é a recomendação. Ela é deliberadamente enxuta: só escalares, nenhum objeto aninhado. Custo zero de instanciação — é por isso que ela suporta first: 200, o teto (§5) — e é tudo o que o passo 1 precisa entregar: a lista do que mudou, para alimentar o passo 3. Os relacionamentos e os nomes vêm no detalhe.

🔴 includeDeleted: true não é opcional nesta rotina. Sem ele — que é o padrão — a listagem devolve só os ativos, e a exclusão de apontamento fica invisível para você: a sua cópia acumula para sempre registros que já não existem, sem erro nenhum e sem nada na resposta que denuncie a ausência. É a diferença mais cara entre as duas fontes da sincronização, porque na de comentários o excluído vem sem pedir. Detalhe do parâmetro em §10.

Com o pedido, o apontamento excluído vem na janela — a exclusão carimba a data de atualização, então cai na janela em que aconteceu, sem tratamento especial. Do seu lado a regra é uma linha: deletedAt nulo = ativo; preenchido = remover da sua base.

⚠️ Mas ele vem reduzido, não íntegro. Chegam preenchidos apenas os identificadores — id, guid, code, projectId — e a autoria da exclusão — deletedAt, deletedByUserId, deletedByUser. Todo o resto vem vazio, mesmo que você peça na seleção: título, descrição, situação, prioridade, visibilidade, prazo, datas de criação e edição e os usuários correspondentes chegam nulos; as coleções (comments, history, images, disciplines, labels, locals, viewpoints, referências a documentos e a modelos) chegam vazias. Pedir esses campos não é erro — apenas não traz nada. Ramifique por deletedAt antes de ler qualquer outro campo, senão o excluído vira, na sua base, um apontamento ativo sem título.

⚠️ O updatedAt da excluída também não volta preenchido: o recorte usa a data guardada, a resposta não a devolve. Se o seu marcador de posição é o maior updatedAt da página, avance-o pelo fim da janela que você pediu — nunca por um campo que nas excluídas chega nulo.

Apontamento excluído não precisa de detalhe: tire-o da lista do passo 3 — e não adianta tentar, a consulta de detalhe responde "não encontrado" para excluído (§10). O que a listagem entregou é tudo o que existe dele pela API.

⚠️ Exclusões anteriores a 24/03/2025 não aparecem numa janela de data. Até essa data a exclusão era gravada de outra forma e não tocava o updatedAt — ele ficou congelado no valor anterior à exclusão, e nenhum dado histórico foi reescrito. Para recuperá-las, faça uma varredura completa (includeDeleted: true, sem updatedAtFrom/updatedAtTo) uma única vez, paginando até hasNextPage ser false, e reconcilie contra a sua base; depois disso, o incremental basta.

Passo 2 — comentários alterados

query UpdatedComments($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
comments(first: $first, after: $after, filter: $filter) {
pageInfo { endCursor hasNextPage }
comments {
id guid issueId
message
visibility issueVisibility
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
images { id index title original markedUp markedUpThumb }
}
}
}
}

Variáveis: as mesmas do passo 1, com first: 50 — esta seleção tem um relacionamento expandido (images), e a regra de tamanho de página manda encolher a página nesse caso (§5). Se você não trata imagens de comentário, tire images da seleção e volte a first: 200.

Uma chamada por projeto. A consulta devolve uma lista plana de comentários do projeto inteiro — não é preciso varrer apontamento por apontamento.

O comentário excluído vem nesta mesma consulta, com deletedAt e deletedByUserId preenchidos e os demais campos íntegros. Mesma regra do passo 1: preenchido = remover da sua base.

O campo issueVisibility traz a visibilidade do apontamento pai, útil para você decidir o que expor no seu lado sem precisar do detalhe.

Passo 3 — detalhe da união

Monte a união de:

  • os id do passo 1, excluindo os que vieram com deletedAt — esses você marca como removidos e não busca detalhe;
  • os issueId dos comentários do passo 2, exceto os que apareceram só por exclusão de comentário: a exclusão já está no próprio comentário, e não há nada de novo no apontamento por causa dela.
query IssueDetail($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) {
id guid code title description
status priority visibility deadline
createdAt updatedAt editedAt statusUpdatedAt
createdByUserId editedByUserId statusUpdatedByUserId
createdByUser { id name }

category categoryName { id name }
creationPhase creationPhaseName { id name }
resolutionPhase resolutionPhaseName { id name }

disciplines { disciplineId status deadline doneAt discipline { id name abbreviation } }
locals { localId local { id name abbreviation abbreviationTree parentId } }
labels { labelId label { id name abbreviation abbreviationTree parentId } }

images { id guid index title original markedUp markedUpThumb }

history { _id dataTime userId entityType fields }
}
}

Esta seleção é a recomendação para o passo 3, e ela entrega de uma vez: os vínculos com nome resolvido (disciplinas, locais, etiquetas), o nome de quem criou, as imagens e o histórico. Repare no que não está nela: comments (você já os recebeu no passo 2 — pedi-los aqui traz o thread inteiro, incluindo o que já estava sincronizado), notifications, permissions, userPersonalData, viewpoints e bimPins. O porquê de cada ausência está em §8.

createdByUser { id name } está aqui porque é barato resolvê-lo neste passo — um apontamento por chamada, um objeto a mais. Na varredura do passo 1 ele não caberia. A alternativa, quando você prefere guardar só o identificador, é ler a lista de membros do projeto de tempos em tempos (§8).

O campo history traz fields, com o nome do campo alterado, o valor anterior e o valor novo — dá para reconstruir o antes/depois sem nenhuma consulta adicional. Não existe consulta incremental de histórico, então este é o único caminho; como você só visita o que mudou, o volume fica sob controle.

Buscar o detalhe em lotes

O passo 3 é o grosso das chamadas da rodada — uma por apontamento alterado, contra duas por projeto nos passos 1 e 2. Dá para reduzi-lo drasticamente: a listagem aceita filter: { ids: [...] }, então você pode pedir o detalhe da união em lotes paginados, com a mesma seleção acima, em vez de um a um.

query IssueDetailBatch($projectId: Int!, $first: Int!, $after: String, $ids: [Int]!) {
project(projectId: $projectId) {
issues(first: $first, after: $after, filter: { ids: $ids }) {
pageInfo { endCursor hasNextPage }
issues {a mesma seleção do detalhe, sem history}
}
}
}

Use first: 50 — a seleção tem relacionamentos expandidos (§5) —, pagine até hasNextPage ser false, e leia o contrato de filter.ids antes: ele é o filtro estrito, recusa a consulta inteira se um item vier fora de formato, e não preserva a ordem que você informou (§10). Indexe a resposta pelo id de cada item, não pela posição.

O que obriga a manter o passo 3 um a um é o history. Na listagem ele é o log completo de alterações por apontamento — numa página de 50, é a explosão que a seleção de campos manda evitar. Se você espelha o histórico, busque o detalhe individualmente; se não espelha, o lote é o caminho.

Carga inicial

A primeira execução usa as mesmas consultas, sem updatedAtFrom — uma varredura completa por projeto, paginada até o fim. Ela traz o acervo e também os comentários já excluídos e, se você mantiver o includeDeleted: true do passo 1, os apontamentos já excluídos — reduzidos ao aviso de exclusão, como sempre. É esta varredura que recupera as exclusões antigas que nenhuma janela de data alcança (apontamento, anteriores a 24/03/2025; comentário, anteriores a 08/2026).

⚠️ Com uma ressalva: nem a carga inicial nasce completa. Comentário de apontamento excluído nunca é devolvido pela listagem de comentários — nem numa varredura sem janela de data (§9). Se o seu modelo precisa da conversa de apontamentos já apagados, esse dado não existe pela API, e nenhuma recarga o traz.

Depois disso, o incremental basta — com a recarga periódica de §9 como rede de segurança.

A carga inicial é a operação mais pesada da integração inteira. Faça-a fora do horário de pico, com o mesmo limitador de taxa de sempre, projeto a projeto — não em paralelo entre projetos.

Custo por rodada

Com N projetos, A apontamentos alterados e janelas curtas:

rodada normal = 1 (descoberta) + 2N (listagens) + detalhe
detalhe = A (um a um) ou A / 50 (em lotes por filter.ids)

rodada em que o cadastro é relido = a de cima + N (uma chamada de cadastro por projeto)

O cadastro não entra em toda rodada: ele é relido periodicamente, não a cada passagem (§6).

Um exemplo concreto: 50 projetos e 800 apontamentos alterados no dia.

Estratégia do passo 3ChamadasA ~4,5 req/s
Um a um1 + 100 + 800 = 901pouco mais de 3 minutos
Em lotes de 501 + 100 + 16 = 117menos de 30 segundos

Nas rodadas em que o cadastro é relido, some 50 chamadas. O custo cresce com a atividade da obra, não com o tamanho do acervo.

Acrescente uma página vazia a mais por listagem sempre que o total for múltiplo exato do tamanho da página: a sinalização de próxima página tem falso positivo nesse caso (§10). É esperado, e o laço converge.


8. Seleção de campos

Pedir menos campos é a economia mais barata que existe: não muda arquitetura e reduz tempo de resposta, tráfego e custo dos dois lados. E é o que forma o preço da operação (§5) — campo escalar folha custa zero; objeto aninhado custa, vezes o tamanho da página.

Campos a evitar, e por quê

CampoPor que evitar
notifications⚠️ É o único campo do apontamento que não é resolvido em lote: dispara uma consulta dedicada por apontamento. Numa página de 200, são 200 consultas escondidas atrás de um campo. Se você precisa do agregado, use notificationsSummary { count mentions last }, que é resolvido junto com o resto
comments dentro do detalheRepetição. Você já recebeu os comentários alterados no passo 2. Aqui ele traz o thread inteiro, incluindo tudo que já estava sincronizado
comments dentro da listagemExplosão N×M: uma página de 50 apontamentos com comments { images { ... } } instancia milhares de objetos
history na listagemLog completo de alterações por apontamento. No detalhe é aceitável; na varredura, não
viewpoints, bimPins, ccodeDocumentsReferences, documentsReferencesDados pesados de BIM e de documentos, com conteúdo estruturado (câmeras, planos de corte, componentes). Ver abaixo
permissionsDiz o que o usuário da credencial pode fazer naquele apontamento. Raramente útil a um espelho, que replica dados e não autorização
userPersonalDataEtiquetas pessoais e favorito do usuário da credencial — não do autor do apontamento
mentionedUsersSó faz sentido se você trata menções
project { ... } aninhadoRepete os dados do projeto em cada item da resposta. Busque o projeto uma vez, à parte
createdByUser { ... }, editedByUser { ... } na varreduraUm objeto por item. Na varredura, prefira o escalar createdByUserId / editedByUserId; peça o nome aninhado no detalhe, onde é um apontamento por chamada (§7)

⚠️ modelReferences não é um campo a evitar — é um campo que não existe na saída. Ele só existe como argumento de entrada de createIssue / updateIssue. Pedi-lo numa seleção falha a consulta inteira na validação do esquema (Cannot query field "modelReferences" on type "Issue"), e nenhum dado volta. Não confunda com ccodeDocumentsReferences e documentsReferences, que são campos de saída legítimos — pesados, mas resolvidos em lote.

viewpoints e bimPins — só no fluxo BIM

São a posição do apontamento no modelo 3D. Vêm de outro banco, o conteúdo é estruturado e o tamanho da resposta cresce rápido.

A regra é o fluxo, não o campo: peça nos fluxos em que a posição 3D é usada, e deixe de fora nos demais. Numa varredura de sincronização eles quase nunca fazem falta; num fluxo de verificação de modelo são exatamente o que importa.

Deixe a consulta resolver os nomes

Quando você pede disciplines { discipline { name } }, locals { local { ... } }, labels { label { ... } } ou createdByUser { name }, a API resolve tudo isso em lote para todos os itens da requisição — não é uma consulta por apontamento. Peça esses nomes onde precisar deles: é mais barato do que a alternativa e sempre traz o valor atual.

Para nome de usuário você tem duas opções, e as duas servem:

  • Aninhar na consultacreatedByUser { id name } / editedByUser { id name }, resolvidos em lote como os demais. Traz sempre o valor atual, e é o caminho quando você precisa do nome em uma consulta que já está fazendo.
  • Ler a lista de membros do projetoproject(id: $projectId) { users { id name lastName email removed } }. É a tradução de identificador em nome para o projeto inteiro, numa chamada, e também é a fonte dos identificadores quando você precisa mencionar alguém num comentário (§11). Releia-a com frequência baixa, como o resto do cadastro; a marca removed diz quem saiu do projeto sem sumir do histórico.

⚠️ O que não vale é guardar createdByUserId sem nunca resolver o nome — nem aninhado, nem pela lista de membros. Você fica com um identificador que o seu lado não sabe exibir.

O que não vale a pena é sincronizar os catálogos completos do projeto a cada rodada só para traduzir identificador em nome. Isso troca uma resolução barata por um download recorrente de tabelas inteiras, e ainda deixa a sua tradução desatualizada entre uma sincronização e outra.

Os catálogos de §6 continuam sendo o lugar certo para outro propósito: quando você precisa da lista completa de opções — preencher um seletor na sua interface, ou validar um valor antes de escrever. Aí sim vale sincronizar, com frequência baixa.

Rótulos que a API não traduz

status, priority e visibility vêm como código. Não existe statusName nem equivalente: o de-para para português (ou para o vocabulário do seu sistema) é responsabilidade sua.

CampoValores
statusactive · resolved · reproved
prioritylow · medium · high
visibilitycreator · group · project_management · public

O valor group endereça um grupo do projeto — um subconjunto nomeado de usuários. O conceito, de onde vem o identificador e o que a API não expõe estão em §6.

A situação por disciplina (disciplines { status }) tem vocabulário próprio, e quais valores estão ativos é configuração de cada projeto — consulte disciplinesStatus e filtre por active (§6) em vez de fixar a lista no código. O conjunto completo:

Situação da disciplinaGrupoMantém o apontamento ativo?
participate, follownão responsávelnão
todo, awaiting, aware, doing, reviewativa (pendência)sim
validate, validatedresolvidasim
doneresolvidanão

⚠️ A terceira coluna é o que a escrita cobra. validate e validated são "resolvida" na leitura humana e mantêm o apontamento active para a plataforma — é o desencontro que mais recusa alteração (§11).

Situação do apontamento não é estado de arquivo. O apontamento está ativo ou excluído; status é fluxo de trabalho, e apontamento resolved continua aparecendo normalmente em todas as consultas. Não existe "arquivado".

Imagens

Cada imagem traz três URLs, com papéis diferentes:

CampoO que é
originalO arquivo como foi enviado, sem marcações
markedUpO render com as marcaçõesé esta que se exibe
markedUpThumbMiniatura do render com marcações

⚠️ As URLs de imagem são públicas e permanentes. Não expiram e não exigem autenticação: quem tiver o endereço vê o arquivo, dentro ou fora do Construflow. Se você armazena essas URLs, elas herdam a mesma característica na sua base — leve isso em conta antes de repassá-las a um sistema com controle de acesso diferente do nosso, ou de embuti-las num relatório que circula por e-mail.


9. Exclusões e reconciliação

Uma cópia espelhada acerta o que chega. O problema de qualquer espelho é o que não chega — e aqui a lista é conhecida. Esta seção é a matriz honesta do que você detecta e do que não detecta.

O que você detecta

O que aconteceuDetecta?Como
Apontamento criado ou alterado§7 passo 1 — cai na janela de data
Apontamento excluídosó se você pedir§7 passo 1 — vem na listagem apenas com filter.includeDeleted: true, reduzido a identificadores + deletedAt/deletedByUserId. Sem o parâmetro, a exclusão é invisível
Comentário novo ou editado§7 passo 2
Comentário excluído§7 passo 2 — vem na listagem com deletedAt e deletedByUserId
Disciplina, local ou etiqueta desvinculada pela alteração do apontamentoA alteração carimba a data do apontamento; o detalhe traz os vínculos atuais
Projeto que muda de situação (ativo → inativo)O status em §6

O que você NÃO detecta

O que aconteceuDetecta?Por que não
Etiqueta ou local apagado no projetoAs linhas de vínculo são destruídas sem tocar a data de atualização de nenhum apontamento. Uma etiqueta apagada some de N apontamentos e nenhum entra na janela
Comentário de apontamento excluídoA listagem de comentários não devolve comentário de apontamento excluído, esteja ele apagado ou não. Excluir um apontamento não marca os comentários dele — você fica sabendo pelo próprio apontamento. E não há a outra ponta: o excluído volta com comments vazio
Conteúdo de um apontamento excluídoNão existe caminho na API. A listagem entrega o aviso de exclusão sem conteúdo, o detalhe responde "não encontrado", e as coleções aninhadas nele vêm vazias. O que você precisar do texto, das imagens ou do histórico tem de estar na sua cópia, gravado antes da exclusão
Mudança de visibilidade ou de grupo que tira o apontamento do seu acessoO apontamento simplesmente para de aparecer nas suas consultas. Não há evento, não há erro, e a sua cópia mantém para sempre um registro que a credencial já não pode ver
Perda de acesso ao projeto⚠️ parcialOs apontamentos somem em silêncio — a listagem devolve vazio, não erro. Só duas coisas denunciam: o projeto desaparecer da lista de §6, ou a consulta ao projeto passar a devolver erro de permissão. Uma rodada que voltou vazia num projeto ativo é sempre suspeita

⚠️ Repare no caso da etiqueta apagada, porque ele é contraintuitivo: a desvinculação pelo apontamento é detectável, e a desvinculação pelo cadastro do projeto não é. São dois caminhos para o mesmo resultado visível, com sinais opostos. Se você usa etiqueta ou local para relatório, a sua base vai acumular vínculos para itens que já não existem.

Mitigação específica e barata: toda vez que você reler o cadastro do projeto — periodicamente, não a cada rodada (§6) —, compare-o com os vínculos que você guardou. Etiqueta ou local que sumiu do catálogo deve ser removido dos seus vínculos, sem precisar reler apontamento nenhum. A defasagem é, no máximo, o intervalo entre duas releituras do cadastro.

A recarga total periódica

Nenhuma das mitigações acima cobre tudo. Programe uma recarga total periódica por projeto — mensal, por exemplo — como rede de segurança:

  1. Rode as consultas do passo 1 e do passo 2 sem janela de data, paginando até o fim.
  2. Compare o conjunto de identificadores recebido com o que você tem armazenado para aquele projeto.
  3. O que existe na sua base e não veio na varredura: removeu-se, mudou de visibilidade, ou o acesso mudou. Em qualquer dos casos, não deveria mais estar visível para você.

É a única forma de fechar os buracos da tabela acima, e é prática padrão de qualquer base espelhada. Agende fora do horário de pico e respeite o mesmo limitador de taxa da rodada normal (§5) — a recarga é pesada, mas não é urgente.


10. Consultas e filtros

Referência completa da leitura. A sincronização de §7 usa um recorte disto; esta seção é o vocabulário inteiro, para consulta sob demanda, relatório e busca.

Listagem de apontamentos

Campo aninhado em Project.issues, paginado por cursor.

query ListIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
project(projectId: $projectId) {
id
issues(first: $first, after: $after, filter: $filter) {
issues {
id guid code title description
status priority visibility deadline
category creationPhase resolutionPhase
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
disciplines { disciplineId status deadline doneAt discipline { name abbreviation } }
locals { localId local { name abbreviationTree } }
labels { labelId label { name abbreviationTree } }
images { id index title markedUp }
}
pageInfo { endCursor hasNextPage }
}
}
}

Retorno: IssuesPaginated { issues: [Issue], pageInfo: { endCursor, hasNextPage } }.

A seleção acima é a de uma tela de listagem — traz os relacionamentos com nome resolvido, ao custo de cerca de 15 objetos por apontamento. Por isso ela pede uma página da ordem de 50, não 200 (§5). Para uma varredura de sincronização, use a seleção enxuta do passo 1, que é só escalar e suporta first: 200. Para o detalhe de um apontamento específico, use a consulta de detalhe em vez de engordar a listagem.

Paginação e cursor

ArgumentoContrato
first: IntTamanho da página. Máximo 200, e ajustado à seleção (§5). Passe sempre explícito — o padrão é 500, acima do teto
after: StringCursor: exatamente o pageInfo.endCursor da página anterior. null ou omitido na primeira página
pageInfo.endCursorCursor da última linha da página
pageInfo.hasNextPageSinaliza se há próxima página

O cursor é um inteiro não-negativo, e deve ser devolvido exatamente como veio. Qualquer outra coisa — texto, objeto, negativo, notação científica — falha a consulta com INVALID_PAGINATION_CURSOR, antes de qualquer acesso ao banco.

⚠️ first negativo não é recusado pelo teto — ele quebra a consulta. O que existe é um teto de 200, não um piso: first: -1 passa pela validação de forma e estoura mais adiante, com 200 e um erro interno genérico. Trate first como inteiro entre 1 e 200 do seu lado.

⚠️ A armadilha real é a dupla codificação. endCursor é declarado String mas transporta um número. Cliente que re-serializa a resposta e devolve o cursor com as aspas embutidas ("\"1615091\"") é recusado, não desembrulhado — a plataforma aceita um formato só. Paginação que funcionava e passou a falhar com INVALID_PAGINATION_CURSOR é, quase sempre, dupla codificação do lado do cliente. Trate o cursor como opaco: guarde a string como veio e devolva a string como veio.

⚠️ hasNextPage tem falso positivo quando o total é múltiplo exato do tamanho da página. Com 400 resultados e first: 200, a segunda página vem cheia e ainda assim hasNextPage é true; a terceira chamada devolve zero itens e false. Itere até hasNextPage ser false — uma chamada vazia a mais é esperada e não é erro. Não pare no primeiro retorno vazio como se fosse anomalia, e não deduza o fim pela contagem de itens.

O objeto filter

filter é um objeto JSON livre. Todas as chaves são opcionais e combinam via AND.

ChaveTipoO que faz
standardStringFiltro predefinido (tabela abaixo)
codeIntCasa o código do apontamento exatamente
textStringBusca textual em título e descrição
ids[Int]Restringe a um conjunto explícito. ⚠️ Validação estrita — não tolera item inválido e não trata [] como "sem filtro"
disciplineIds[Int]Apontamentos associados a qualquer uma das disciplinas (OR entre os ids)
labelIds[Int]Apontamentos associados a qualquer uma das etiquetas (OR entre os ids)
localIds[Int]Apontamentos associados a qualquer um dos locais (OR entre os ids)
ccodeDocumentIdStringApontamentos vinculados a um documento na ConstruCode (CCode), a plataforma externa de gestão documental integrada ao projeto. O identificador é texto, não número
documentIdStringApontamentos vinculados a um documento do CDE (Common Data Environment) do próprio Construflow. Também texto
withViewpointsBooleanLiga o filtro "tem viewpoints". false não traz os sem viewpoint — desliga o filtro
updatedAtFromStringISO 8601 — criados ou editados a partir desta data (inclusivo)
updatedAtToStringISO 8601 — criados ou editados até esta data (inclusivo)
includeDeletedBooleanTraz também os apontamentos excluídos, junto com os ativos — reduzidos ao aviso de exclusão, sem conteúdo. Padrão false

⚠️ Chave que não está na tabela acima não existe — e não dá erro. filter é um objeto livre: a tabela desta seção é a lista fechada do que a listagem de apontamentos entende, e qualquer outra chave é aceita, ignorada e respondida com status 200, sem erro e sem aviso. São dois enganos comuns, com o mesmo desfecho:

  • Grafia errada{ disciplineId: 3 }, sem o s: o filtro simplesmente não existe, e a consulta vira varredura total do projeto. É o mesmo desfecho de mandar uma data inválida.
  • Chave emprestada de outra consulta — cada listagem tem a sua tabela de contrato, e elas não coincidem. includeDeleted vale aqui e não existe na listagem de comentários do projeto, onde é ignorado: lá o excluído vem sempre, e não há como pedir só os ativos.

🔴 Resposta bem-sucedida não prova que o parâmetro existe. Nada na resposta denuncia uma chave ignorada — o único jeito de saber o que uma consulta aceita é a tabela de contrato dela, neste documento. Toda vez que uma consulta filtrada devolver muito mais do que deveria, confira a grafia das chaves e se elas pertencem a esta listagem antes de qualquer outra coisa.

Filtro nunca expande permissão. Todos os filtros respeitam a visibilidade dos apontamentos (creator, group, project_management, public) e as permissões do usuário da credencial. Eles apenas reduzem o conjunto que a permissão já permitia; nunca o ampliam. Um resultado vazio pode significar "não existe" ou "você não pode ver" — não há como distinguir pela resposta.

filter.standard

ValorO que traz
pendenciesApontamentos com pendências atribuídas ao usuário da credencial. O conjunto de situações depende do perfil — ver abaixo
latePendenciesIdem, mas apenas os com prazo vencido
notificationsApontamentos com notificações não lidas para o usuário
newMentionsApontamentos com menção não lida ao usuário — de qualquer época
validateApontamentos pendentes de validação. Exige papel de coordenação — ver abaixo
starredApontamentos marcados como favoritos pelo usuário
activesApenas os com status = "active"
(omitido, null ou valor desconhecido)Todos os apontamentos visíveis ao usuário — valor fora da lista não é erro, é "sem filtro"

Repare que quase todos são relativos ao usuário da credencial, não ao projeto. Numa integração, actives costuma ser o único de uso geral.

⚠️ pendencies não tem um conjunto fixo de situações — ele sai do perfil de quem consulta. Quem tem papel de coordenação no projeto vê mais situações do que um colaborador comum, incluindo validate e validated, que este documento classifica como resolvidas (§8):

Perfil no projetoSituações de disciplina que contam como pendência
Coordenação / administraçãotodo, awaiting, aware, doing, review, validate, validated
Colaboradortodo, awaiting, aware, doing, review
Só visualizaçãonenhuma — devolve lista vazia

done nunca conta como pendência, para nenhum perfil. E há uma segunda condição: o filtro exige que o usuário seja responsável por alguma disciplina do apontamento — um coordenador sem disciplina atribuída recebe lista vazia.

⚠️ validate devolve sempre lista vazia para credencial sem papel de coordenação. Não é erro, não é "nada pendente": é o filtro não sendo aplicável ao perfil. Se a sua integração usa validate, confirme antes que o usuário dono da chave tem esse papel no projeto.

⚠️ newMentions não tem janela temporal. É menção não lida, sem corte de data: uma menção de 2023 que ninguém marcou como lida continua aparecendo. Ela só sai da lista quando a notificação é apagada — o usuário marca como lida, o apontamento é excluído, ou o usuário perde a visibilidade dele.

filter.code

  • Tipo Int. Aceita também string numérica ("1234") — coerção automática.
  • Valor não numérico ("abc"), null ou omitido é silenciosamente ignorado: o filtro não é aplicado.

filter.ids — o filtro estrito

⚠️ Este filtro não segue a tolerância dos vizinhos. Onde disciplineIds / labelIds / localIds descartam item inválido em silêncio e degradam para "sem filtro", ids recusa a consulta inteira. Se você escreveu o seu cliente por analogia com os outros filtros de lista, é aqui que ele quebra.

  • Tipo [Int]. Item válido = inteiro positivo, ou a string numérica equivalente ("1234").
  • Qualquer item inválido0, negativo, null, string não numérica, objeto — faz a consulta falhar com INVALID_ISSUES_IDS_FILTER. Nada é descartado em silêncio e nenhum apontamento é devolvido.
  • Valor que não é lista (12, "12", {}) produz a mesma recusa.
  • ⚠️ [] significa conjunto vazio, não "sem filtro". Devolve zero apontamentos — o oposto de disciplineIds: [], que desliga o filtro. A assimetria é deliberada: "quero exatamente estes apontamentos, e a lista está vazia" não pode degradar para "quero todos". Um cliente que monta a lista dinamicamente e às vezes a monta vazia precisa omitir a chave, não mandar [].
  • Chave omitida, null ou undefined ⇒ filtro não aplicado — aí sim, igual aos vizinhos.
  • Ids duplicados são deduplicados.
  • ⚠️ A ordem informada não é preservada, e a ordem do array na resposta não é garantida em nada. A resposta é servida em parte por cache, que devolve primeiro o que estava quente — a mesma consulta pode voltar em ordens diferentes. Indexe pelo id de cada item e ordene do seu lado. Isso não afeta a paginação: o cursor continua confiável, porque a página é recortada por identificador crescente, não pela ordem do array.
  • Id que existe mas não é visível ao usuário simplesmente não casa nada. A recusa acima é sempre sobre formato, nunca sobre existência ou permissão.

filter.disciplineIds, filter.labelIds, filter.localIds

Os três têm exatamente a mesma semântica:

  • Tipo [Int]. Cada elemento deve ser inteiro positivo; string numérica ("3") é aceita e convertida; 0, negativos, null, NaN e strings não numéricas são silenciosamente ignorados; duplicados são deduplicados.
  • OR entre os ids da mesma chave, AND entre chaves diferentes.
  • Consideram apenas vínculos ativos: vínculo removido é ignorado mesmo que o id corresponda.
  • ⚠️ Match exato — não expande hierarquia. Filtrar por um local pai (um pavimento) não inclui apontamentos vinculados apenas aos filhos (as salas); o mesmo vale para etiquetas. Se você quer a subárvore, envie também os ids dos filhos — a árvore vem em §6.
  • Id que não pertence ao projeto, ou inexistente, simplesmente não casa nada — sem erro, sem aviso.
  • [], null, undefined ou chave omitida ⇒ filtro não aplicado (as quatro formas são equivalentes).

filter.text

  • Busca substring, sem diferenciar maiúsculas, no título e na descrição.
  • A string é tokenizada por espaço. Cada token vira um filtro independente combinado via AND, e cada token, individualmente, casa em título OU descrição.
  • %, _ e $ são tratados como literais — não atuam como curinga.
  • String vazia ou só com espaços é ignorada. Comprimento truncado em 200 caracteres.
filter.textCasa em
"infiltração"título OU descrição contendo "infiltração"
"rodrigo ferreira"contém "rodrigo" e (independentemente) contém "ferreira" — inclusive "Ferreira, rodrigo"
"trinca pequena""trinca" e "pequena" aparecem, em qualquer combinação de título/descrição
"50%"casa 50% literal — % não é curinga

filter.updatedAtFrom / filter.updatedAtTo

  • Devolve apontamentos criados ou editados no período. Cada borda é opcional; ambas são inclusivas.
  • ISO 8601, e envie sempre o fuso explícito. Só a data pura ("2026-01-15") é UTC por especificação — e é meia-noite UTC, então updatedAtTo: "2026-01-31" exclui o dia 31 inteiro. Data com hora e sem fuso ("2026-01-15T10:00:00") é resolvida contra o relógio do processo do servidor, que não é contrato. Detalhe em §7.
  • updatedAtFrom maior que updatedAtTo devolve lista vazia, sem erro.
  • Valor inválido (não-string, data não parseável, null, "") é silenciosamente ignorado — e a consulta vira varredura total.
  • ⚠️ Não filtra a data dos comentários. Comentário novo não altera a data de atualização do apontamento. Para comentários, use a listagem de comentários do projeto.

filter.includeDeleted — pedir os excluídos

Sem este parâmetro a listagem devolve só os ativos, exatamente como sempre devolveu. Com ele, o conjunto de sempre mais os apontamentos excluídos. É a única via da API para descobrir que um apontamento foi apagado — e é o passo 1 da sincronização (§7).

  • Ligam o filtro: o booleano true e a string "true". ⚠️ Qualquer outro valor (1, "1", "TRUE", null, chave omitida) cai no padrão false, sem erro e sem aviso — um valor "quase certo" devolve silenciosamente só os ativos, e a sua detecção de exclusão nunca dispara.
  • Nada muda nos ativos: nenhum apontamento ativo sai da resposta, nenhum campo deles muda. O que muda é o conteúdo dos excluídos, que vêm reduzidos a identificadores e autoria da exclusão — ver §7 passo 1 para a lista exata do que chega preenchido.
  • É honrado em todos os recortes por atributoupdatedAtFrom/updatedAtTo, code, text, disciplineIds, labelIds, localIds, ids, ccodeDocumentId, documentId, withViewpoints —, que passam a recortar também sobre os excluídos.
  • ⚠️ O recorte enxerga o conteúdo do excluído; a resposta não o devolve. Um apontamento excluído pode entrar na página por casar text, disciplineIds ou withViewpoints e ainda assim chegar com título, descrição, disciplinas e viewpoints vazios. Não tente conferir pelo payload por que ele casou, nem reaplicar o filtro no cliente sobre o que voltou — você descartaria exclusões legítimas.
  • ⚠️ É ignorado junto com um filter.standard de trabalho (pendencies, latePendencies, notifications, newMentions, validate, starred, actives): a resposta traz só ativos, sem erro. Esses recortes descrevem o trabalho corrente do usuário, e apontamento excluído não está nele. Se você precisa dos excluídos, não use standard.
  • Não afeta permissão nem visibilidade: apontamento que a credencial não podia ver continua invisível, excluído ou não.
  • Não muda ordenação nem cursor — os excluídos contam como itens comuns da página.

Erros de formato

A doutrina geral deste contrato é tolerância: filtro malformado é ignorado e a consulta segue. Mas a tolerância não é universal — há entradas fora de formato que derrubam a consulta, e elas se dividem em duas famílias muito diferentes na hora de tratar.

Família 1 — as duas com código próprio e mensagem legível. São as únicas recusas de formato que você pode reconhecer programaticamente:

Entrada fora de formatoerrors[0].extensions.code
after (cursor de paginação)INVALID_PAGINATION_CURSOR
filter.idsINVALID_ISSUES_IDS_FILTER

⚠️ As duas respondem 200, não 400. O campo consultado é nulável, então o erro nulifica o campo e a resposta permanece 200 com data presente e issues nulo. É mais um caso concreto da regra de §4: verifique errors antes de ler data — quem lê data direto vê um campo nulo e conclui "o projeto não tem apontamentos". O extensions traz o code; a mensagem legível vem em errors[0].message.

A regra do cursor vale igualmente para a listagem de comentários do projeto, que usa o mesmo cursor. Quanto ao momento: o cursor é validado antes de qualquer acesso ao banco; o filter.ids é validado um pouco depois, já com o registro de acesso gravado — a diferença não muda o que você faz, mas a recusa de ids não é tão barata quanto a do cursor.

Família 2 — as que produzem erro interno genérico. Também derrubam a consulta, também com 200, mas sem código próprio: chegam como INTERNAL_SERVER_ERROR, com uma mensagem crua vinda da camada de dados. Não há nada a distinguir programaticamente:

Entrada fora de formatoO que acontece
filter.documentId com número (123) em vez de texto ("123")erro interno — o campo é texto
filter.ccodeDocumentId com objetoerro interno
first negativoerro interno

Não escreva tratamento por código para estas: previna-as validando o tipo do seu lado. Se uma consulta que "sempre funcionou" passou a devolver INTERNAL_SERVER_ERROR, o primeiro lugar a olhar é o tipo dos valores que você montou no filter.

A tolerância vale para o conteúdo de um filtro reconhecido. Ela não vale para a forma nem para o tamanho da consulta: profundidade, tamanho de página, preço e array de operações têm tetos próprios, e a recusa deles é 400 com código próprio (§4, §5).

Exemplos

Filtro predefinido:

query {
project(projectId: 42) {
issues(first: 20, filter: { standard: "actives" }) {
issues { id code title status priority deadline }
pageInfo { endCursor hasNextPage }
}
}
}

Busca textual com tokenização:

query {
project(projectId: 42) {
issues(first: 20, filter: { text: "infiltração laje" }) {
issues { id code title description }
pageInfo { endCursor hasNextPage }
}
}
}

Por etiquetas e locais (OR dentro de cada chave, AND entre elas):

query {
project(projectId: 42) {
issues(first: 20, filter: { labelIds: [11, 12], localIds: [201] }) {
issues {
id code title
labels { labelId label { name } }
locals { localId local { name abbreviationTree } }
}
pageInfo { endCursor hasNextPage }
}
}
}

Janela de alteração — as duas pontas:

query {
project(projectId: 42) {
issues(
first: 200
filter: {
updatedAtFrom: "2026-01-01T00:00:00Z"
updatedAtTo: "2026-01-31T23:59:59Z"
}
) {
issues { id code title updatedAt deletedAt }
pageInfo { endCursor hasNextPage }
}
}
}

Combinação (AND entre as chaves):

query {
project(projectId: 42) {
issues(
first: 20
filter: { standard: "actives", text: "umidade", disciplineIds: [101, 102], labelIds: [11] }
) {
issues { id code title }
pageInfo { endCursor hasNextPage }
}
}
}

Detalhe de um apontamento

query IssueById($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) { id guid code title status updatedAt }
}

Argumentos: projectId: Int!, issueId: Int!. Retorno: Issue — todos os campos da listagem, mais categoryName, creationPhaseName, resolutionPhaseName, history, comments, viewpoints, bimPins, notificationsSummary e as referências de documento (ccodeDocumentsReferences, documentsReferences). Não existe modelReferences na saída — pedi-lo falha a consulta (§8). A seleção completa recomendada está em §7 passo 3.

Use para o detalhe de um apontamento específico, depois de selecioná-lo numa listagem. Para buscar o detalhe de muitos apontamentos, prefira a listagem com filter.ids, em lotes paginados (§7) — o laço um a um só se justifica quando você precisa de history.

Erros:

  • Apontamento excluído e apontamento inexistente respondem igual — NOT_FOUND (semântica de "não encontrado"), com status 200 e o erro em errors[0]. São indistinguíveis por esta consulta, de propósito: nos dois casos não há apontamento visível para aquele identificador.
  • Apontamento de outro projeto, ou sem permissão de visualização, responde FORBIDDEN.

⚠️ Esta consulta nunca devolve apontamento excluído. Se a sua sincronização recebeu o identificador de um excluído pela listagem, não tente completar aqui o que faltou lá: o detalhe responde "não encontrado", e o conteúdo de um apontamento excluído não é acessível por nenhum caminho da API — a listagem já entregou tudo o que existe dele (§7 passo 1).

⚠️ Mudança de contrato: identificador inexistente respondia erro genérico de servidor (INTERNAL_SERVER_ERROR) e passou a responder NOT_FOUND. Se o seu cliente aplica backoff e retry em erro de servidor (§4), um identificador inválido deixa de virar retentativa — trate NOT_FOUND como resposta final.

Comentários de um apontamento

Campo aninhado em Issue.comments. Traz o thread completo do apontamento, sem paginação.

query IssueComments($projectId: Int!, $issueId: Int!) {
issue(projectId: $projectId, issueId: $issueId) {
id
comments {
id guid issueId message visibility
createdAt updatedAt editedAt
createdByUserId editedByUserId
deletedAt deletedByUserId
groupId
images { id index title markedUp markedUpThumb }
}
}
}

Retorno: [Comment].

Serve a uma tela de conversa. Não serve a sincronização: para acompanhar comentários em massa, use a listagem plana abaixo.

Comentários do projeto

Campo aninhado em Project.comments. Devolve todos os comentários visíveis do projeto — inclusive os excluídos — numa lista plana e paginada, com filtro opcional por data de alteração. É a consulta do passo 2 da sincronização (§7).

Argumentos:

ArgumentoContrato
first: IntTamanho da página. Máximo 200, e ajustado à seleção (§5). Passe sempre explícito — o padrão é 500, o mesmo da listagem de apontamentos
after: StringMesmo contrato do cursor de apontamentos: inteiro não-negativo, devolvido como veio. Fora de formato ⇒ INVALID_PAGINATION_CURSOR, com status 200
filter.updatedAtFromISO 8601 — comentários criados, editados ou excluídos a partir desta data (inclusivo)
filter.updatedAtToISO 8601 — idem, até esta data (inclusivo)

⚠️ A tabela acima é fechada — não existe includeDeleted nesta consulta. Ele é chave da listagem de apontamentos; aqui é aceito, ignorado e respondido com 200, sem erro e sem aviso. O comentário excluído vem sempre, e não há como pedir só os ativos.

Retorno: CommentsPaginated { comments: [Comment], pageInfo: { endCursor, hasNextPage } }.

Notas de comportamento:

  • Ordenação fixa por ordem de criação; o cursor é o identificador do último comentário da página.
  • Data inválida é silenciosamente ignorada. ⚠️ O cursor não é — a tolerância vale para os filtros de data, não para a paginação.
  • A página pode devolver até first comentários visíveis: a visibilidade é avaliada depois da paginação, então uma página pode vir com menos itens do que o pedido sem que isso signifique fim dos dados. Itere até hasNextPage ser false.
  • O conjunto devolvido é exatamente o que o usuário já enxerga via Issue.comments. Esta consulta achata a listagem; não expande o acesso.

Comentário excluído permanece na listagem, com deletedAt e deletedByUserId preenchidos e os demais campos íntegros. A exclusão conta como alteração e carimba a data de atualização, então cai na janela em que aconteceu, sem tratamento especial: deletedAt nulo = ativo, preenchido = excluído.

⚠️ Comentário de apontamento excluído não é devolvido, esteja ele apagado ou não — excluir um apontamento não marca os comentários dele. E também não há como alcançá-los pela outra ponta: o apontamento excluído volta com comments vazio. Quem precisa reagir à exclusão observa o próprio apontamento, pedindo os excluídos na listagem (§7 passo 1).

⚠️ Atenção à assimetria com apontamentos — ela é de forma e de conteúdo. Aqui o excluído vem sempre, sem parâmetro, e com a mensagem íntegra; lá o apontamento excluído vem só sob pedido (filter.includeDeleted: true) e sem conteúdo algum — apenas identificadores e autoria da exclusão. Quem espelha as duas entidades não consegue tratá-las pelo mesmo caminho de código: o comentário excluído continua um registro legível na sua base; do apontamento excluído não há o que espelhar além do identificador para removê-lo.


11. Escrita

Escrever exige permissão de participar do projeto. Sem ela, a operação falha na autorização — com status 200 e a lista errors preenchida (§4).

Uma exceção: copiar apontamentos entre projetos exige papel de gestão no projeto de destino (lote). Participar não basta ali.

Criar apontamento

mutation CreateIssue {
createIssue(
projectId: 42
title: "Infiltração na laje do 3º pavimento"
description: "Mancha de umidade junto ao pilar P12."
status: "active"
priority: "high"
visibility: "public"
category: 3
creationPhase: 9
locals: [201]
labels: [11]
disciplines: [{ disciplineId: 101, status: "todo", deadline: "2026-02-10" }]
) {
id guid code title status deadline
}
}

Obrigatórios no contrato — são cinco: projectId, title, status, priority, visibility.

Obrigatórios pelas regras de negócio, embora o esquema os aceite ausentes:

ArgumentoQuando é exigidoO que acontece se faltar
localssempre⚠️ ver o aviso abaixo
disciplinessempre⚠️ ver o aviso abaixo
categoryquando o projeto tem o catálogo de categorias configuradorecusa CATEGORY_MUST_BE_ACTIVE, antes de gravar
creationPhasequando o projeto tem o catálogo de fases configuradorecusa PHASE_MUST_BE_ACTIVE, antes de gravar
resolutionPhasesempre que status não for "active"recusa MISSING_RESOLUTION_PHASE, antes de gravar

Se o projeto não restringe categorias e fases, category e creationPhase podem ser omitidos — o projeto aceita o catálogo global inteiro (§6). Consulte o cadastro para saber em qual caso você está.

🔴 Omitir locals ou disciplines cria o apontamento e só depois estoura. A validação desses dois vínculos acontece depois da gravação, e não há transação: você recebe um erro interno genérico, sem o apontamento na resposta — mas o apontamento existe, gravado, com código consumido e sem local nem disciplina. Não reenvie: reenviar duplica. Se você mandou um guid próprio, dá para reconciliar; senão, o registro fica órfão. Mandar locals: [] ou disciplines: [] é diferente e seguro: recusa limpa, antes de gravar (regra 3). Sempre envie os dois, não-vazios.

⚠️ Não envie deadline no topo. O prazo do apontamento é derivado das disciplinas — como disciplines é obrigatório aqui, o valor que você mandar no topo é sempre descartado. O prazo se define no deadline de cada item de InputIssueDiscipline (regra 5).

Demais argumentos aceitos: description: String, deadline: Date, labels: [Int], images: [IssueImageInput], groupId: Int, guid: String, ccodeDocumentsReferences: [IssueCcodeDocumentReferenceInput], documentsReferences: [IssueDocumentReferenceInput], modelReferences: [ModelReferenceInput], viewpoints: [IssueViewpointInput], bimPins: JSON, aiAssistantLogs: JSON.

Os valores de status, priority e visibility estão em §8. Os identificadores de locals, labels, disciplines e groupId vêm do cadastro do projeto (§6); os de category, creationPhase e resolutionPhase vêm de um catálogo global, e precisam estar active neste projeto (§6). Valide-os antes de escrever: identificador de outro projeto não é aceito, e categoria ou fase inativa é recusada.

Você pode enviar o seu próprio guid na criação. É a forma de amarrar o apontamento a um registro do seu sistema sem manter uma tabela de correspondência: o mesmo guid depois identifica o apontamento na alteração.

Input types:

input InputIssueDiscipline {
disciplineId: Int!
status: String # obrigatório na prática — item sem status derruba a operação
deadline: Date # omitir ZERA o prazo gravado da disciplina
doneAt: Date # omitir SOBRESCREVE a data de conclusão gravada — ver a regra 4
remove: Boolean # lido SÓ pela mutation em lote — ver a regra 4
}

input IssueImageInput {
id: Int
guid: String
title: String! # obrigatório
cover: Boolean
deleteImage: Boolean
viewpoint: JSON
markUp: JSON
index: Int
original: String # URL, após o upload pela URL assinada
originalFile: Upload # alternativa por multipart
markedUp: String # a versão que se exibe — ver "Imagens em três passos"
markedUpFile: Upload
markedUpThumb: String # gerado pelo servidor apenas se markedUp estiver preenchido
markedUpThumbFile: Upload # ≤200×200 PNG, proporção preservada
}

Alterar apontamento

mutation UpdateIssue {
updateIssue(
projectId: 42
issueId: 1234
status: "resolved"
resolutionPhase: 9
disciplines: [
{ disciplineId: 101, status: "done", doneAt: "2026-01-20", deadline: "2026-02-10" }
]
) {
id guid title status deadline updatedAt
}
}

Obrigatório: projectId. O apontamento é identificado por issueId ou por guid — o que for mais conveniente para o seu lado.

Demais campos: os mesmos de createIssue, inclusive as referências de documento e modelo. Envie só os que mudaram — veja as regras abaixo.

Repare no deadline dentro da disciplina, no exemplo: ele não é decoração. Sem ele, esta chamada apagaria o prazo da disciplina e o prazo do apontamento, em silêncio (regra 5).

Alteração parcial: as cinco regras

A alteração é parcial: campo não enviado é preservado. Não é preciso reenviar o apontamento inteiro — e não se deve, porque cada campo enviado é um campo que pode sobrescrever a edição de outra pessoa.

Cinco regras governam o que acontece com o que você envia:

  1. ⚠️ Enviar status exige resolutionPhase no mesmo envio — exceto quando o status enviado é active. O gatilho é enviar, não mudar: reenviar o mesmo status que já estava lá, sem repetir resolutionPhase, é recusado com MISSING_RESOLUTION_PHASE. Um cliente que monta o payload a partir do objeto inteiro cai nisso na primeira alteração. Para não mexer na situação, omita status.

    E o contrário também vale: enviar status: "active" apaga a resolutionPhase gravada, mesmo que você mande um valor junto. Numa reabertura, o registro de em que etapa o apontamento tinha sido resolvido se perde.

  2. ⚠️ status e disciplines têm de ser coerentes entre si. O invariante é simples: o apontamento está active se e somente se ao menos uma disciplina está em situação ativa.

    Situações de disciplina no enviostatus exigido
    ao menos uma em todo, awaiting, aware, doing, review, validate ou validated"active" — qualquer outro é recusado com DISCIPLINE_STATUS_MUST_KEEP_ISSUE_ACTIVE
    todas em done, participate ou follownão "active" — recusado com DISCIPLINE_STATUS_MUST_KEEP_ISSUE_NOT_ACTIVE

    ⚠️ validate e validated mantêm o apontamento ativo, embora sejam situações de "resolvido" na leitura humana (§8). Mandar as disciplinas como validate obriga status: "active".

    Enviar disciplines sem status não é a saída: num dos caminhos a operação é recusada por falta de fase de resolução, exigindo um campo (autoResolutionPhase) que só existe no input da mutation em lote — na alteração individual não há como satisfazê-la. Fechar todas as disciplinas de um apontamento ativo, portanto, exige enviar status junto.

  3. ⚠️ Lista vazia apaga — em uns campos, e é recusada em outros.

    Campo[] significa
    labelsapaga todas as etiquetas
    ccodeDocumentsReferences, documentsReferences, modelReferencesapagam todas as referências
    localsrecusado — o apontamento precisa de ao menos um local
    disciplinesrecusado — o apontamento precisa de ao menos uma disciplina

    Para preservar o valor atual, omita o campo. Nunca mande [] com a intenção de "não mexer". Este é o erro que mais apaga dado em integração nova, porque muitos clientes serializam uma coleção vazia onde deveriam omitir a chave.

  4. ⚠️ A lista de disciplinas enviada é o estado final — é substituição total. Disciplina que está no apontamento e não vem na lista é removida. Para remover uma, omita o item e reenvie as demais; para preservar todas, reenvie todas.

    remove: true não funciona aqui. O campo existe no input e é descartado na alteração individual — só a mutation em lote o lê (lote). Mandar remove: true com a situação presente mantém a disciplina; se o disciplineId não estava no apontamento, ela é criada. E mandar remove: true sem a situação não é ignorado com elegância: responde erro interno.

    E, dentro de cada item, campo omitido não é preservado: um item { disciplineId, status } sem deadline zera o prazo gravado da disciplina, e sem doneAt sobrescreve a data de conclusão gravada — pela data de hoje, nas situações que exigem conclusão (validate, validated, done). Reenvie os dois sempre que quiser mantê-los.

    O servidor ainda normaliza os dois conforme a situação, e o que ele normaliza você não controla: em participate e follow o prazo é anulado, tenha sido enviado ou não; a data de conclusão é anulada em todas as situações exceto validate, validated e done; e nessas três, se você não mandar doneAt, entra a data de hoje.

  5. ⚠️ O prazo do apontamento é derivado das disciplinas — você não o define diretamente. Sempre que disciplines é enviado, o servidor sobrescreve deadline com o maior prazo entre as disciplinas enviadas — ou com nulo, se nenhuma delas tiver prazo. Um deadline mandado no topo, junto com disciplines, é descartado sem aviso.

    Consequência prática: uma alteração que só quis fechar uma disciplina, e mandou [{ disciplineId, status: "done", doneAt }] sem deadline, apaga o prazo do apontamento — sem erro, sem aviso. Mandar deadline isolado (sem disciplines) grava, mas não resolve: a próxima alteração que enviar disciplinas sobrescreve de novo. Trate o prazo como propriedade das disciplinas.

Escrita em lote

Para criar ou alterar muitos apontamentos, use as mutations em lote em vez de disparar chamadas individuais:

MutationPara quê
createIssuesCriar vários apontamentos
updateIssues(projectId: Int!, issuesIds: [Int]!, newValues: IssueNewValuesInput): JSONAplicar os mesmos novos valores a um conjunto de apontamentos
updateIssuesDisciplinesStatus(projectId: Int!, issuesIds: [Int!]!, disciplines: [IssueDisciplineStatusInput!]!, autoResolutionPhase: Int): IssuesDisciplinesStatusResultMover só a situação de disciplinas escolhidas num conjunto de apontamentos — a única escrita que alcança quem não pode alterar o apontamento
copyProjectIssuesCopiar apontamentos de um projeto para outro

updateIssues é uma operação parcial — leia o retorno

O retorno é JSON, e tem esta forma:

{
"updatedIssues": [ { "id": 1234, "…": "…" } ],
"rejectedIssues": [ { "id": 1240, "error": { "status": 403, "code": "ISSUE_MUST_HAVE_LOCALS" } } ]
}

🔴 Apontamento rejeitado não derruba a mutation. A resposta HTTP é 200, a lista errors do GraphQL vem vazia, e as rejeições estão só em rejectedIssues. Quem checa apenas errors conclui que tudo passou e nunca descobre o que ficou para trás. Sempre percorra rejectedIssues.

O objeto error de cada rejeição traz status e code; não traz a mensagem. Trate pelo code.

O input do lote não é o mesmo da alteração individual

IssueNewValuesInput parece o input de updateIssue, e não é:

CampoNo lote
locals, labelsNão são listas de ids. São { "action": "add" | "remove", "ids": [Int] }. action fora desses dois valores derruba a mutation inteira
disciplinesMesclagem, não substituição: item com disciplineId já presente tem só as chaves enviadas atualizadas (omitir deadline preserva o prazo — o oposto da alteração individual); item novo só entra se tiver status, senão é descartado em silêncio; remove: true funciona aqui e tira a disciplina
autoResolutionPhase: IntExiste no lote. É a fase de resolução usada quando o fechamento das disciplinas leva o apontamento a sair de active sozinho
imagesDeclarado no input e nunca processado — aceito e descartado em silêncio
guid, viewpoints, bimPins, referências de documento e de modeloNão existem no input do lote

⚠️ Enviar status: "resolved" ou "reproved" no lote reescreve para done todas as disciplinas ativas dos apontamentos alcançados — inclusive as que você não mencionou.

⚠️ O lote regrava a linha inteira, a partir do estado que leu no início da operação — não só os campos que você mandou. Edição feita por outra pessoa nesse intervalo é revertida, inclusive em campo que o seu newValues nem menciona. A janela é curta, mas existe, e é maior que a da alteração individual (concorrência).

🔴 Identificador inexistente em issuesIds nem sempre vira rejeição — às vezes derruba a rodada inteira. Depende do que você está alterando:

O newValues incluiIdentificador inexistente
Nenhum de disciplines, locals, labels, statusvai para rejectedIssues; os demais são gravados
Qualquer um deleserro interno, e nada é gravado — nem os apontamentos válidos do lote

É a diferença entre perder um item e perder a rodada, e o caso perigoso é justamente o mais comum (alterar situação ou disciplinas). Filtre issuesIds contra o que você sabe existir antes de enviar. Identificador de outro projeto é sempre rejeitado individualmente, sem esse risco.

updateIssuesDisciplinesStatus — mover a situação das disciplinas

Esta operação move a situação das disciplinas de um ou mais apontamentos, e nada mais. Ela é separada de updateIssues por causa de quem a alcança: é a única escrita de apontamento disponível a quem não pode alterá-lo — quem participa do projeto e responde por uma disciplina move a situação dela por aqui, sem ter permissão para tocar no resto do apontamento.

updateIssues continua aceitando disciplines exatamente como antes, e nada foi removido dela: quem já integrou pelo lote comum não precisa mudar nada.

mutation MoverSituacaoDeDisciplinas {
updateIssuesDisciplinesStatus(
projectId: 42
issuesIds: [1234, 1240]
disciplines: [{ disciplineId: 101, status: "doing" }]
autoResolutionPhase: 9
) {
updatedIssues { id status deadline updatedAt disciplines { disciplineId status } }
rejectedIssues { id code message status }
}
}

Cada item de disciplines tem dois campos, e os dois são obrigatórios:

CampoO que é
disciplineIdA disciplina a mover. Ela precisa já estar no apontamento
statusA situação de destino (§8), entre as que estão ativas no catálogo do projeto

Os mesmos itens valem para todos os issuesIds — como no lote comum, não existe alteração heterogênea.

⚠️ A lista não é o estado final: mande só as disciplinas que se movem. É o oposto da alteração individual, em que a lista substitui tudo (alteração parcial, regra 4). Aqui, disciplina que você não mencionar fica intacta — mesma situação, mesmo prazo, mesma data de conclusão. Prazo e data de conclusão você não escreve nem por engano: os campos não existem neste input.

⚠️ autoResolutionPhase é exigido quando o gesto tira a última disciplina que mantinha o apontamento ativo — é a fase de resolução com que ele se fecha, como no lote comum. Faltando nesse caso, o apontamento é recusado com MISSING_RESOLUTION_PHASE. Se o seu gesto não fecha apontamento nenhum, omita o campo.

Quem pode mover o quê:

Quem chamaO que consegue mover
quem pode alterar o apontamentoqualquer disciplina dele, para qualquer situação ativa no catálogo do projeto
quem não pode alterá-lo, mas responde pela disciplinasó as disciplinas pelas quais responde, e só para os destinos que a situação atual alcança

Nessa segunda faixa, três limites não se contornam: de validated e de done não se sai; para validated e para done não se entra — fechar uma disciplina é sempre de quem coordena; e o apontamento precisa ser visível para a sua credencial, senão ele é recusado como inexistente.

O que a operação recusa. Prazo, data de conclusão, acrescentar disciplina e retirar disciplina não existem neste input: o GraphQL recusa o pedido inteiro com erro de campo desconhecido, antes de qualquer coisa ser gravada. Para esses gestos o caminho continua sendo updateIssue ou updateIssues. O resto vira rejeição do apontamento, com code e status:

Situaçãocodestatus
apontamento inexistente — ou existente e invisível para a sua credencialISSUE_NOT_FOUND404
apontamento de outro projetoISSUE_DOES_NOT_BELONGS_TO_PROJECT403
disciplina pedida não está no apontamentoDISCIPLINE_NOT_IN_ISSUE400
disciplina fora do catálogo do projetoDISCIPLINE_NOT_FOUND404
situação desligada no catálogo do projetoDISCIPLINE_STATUS_NOT_AVAILABLE403
você não responde pela disciplina, ou o destino não é alcançável a partir da situação atualFORBIDDEN403
o gesto fecharia o apontamento e autoResolutionPhase não veioMISSING_RESOLUTION_PHASE400

⚠️ Invisível e inexistente respondem igual, de propósito. Nos dois casos vem ISSUE_NOT_FOUND — não conclua que o identificador está errado.

🔴 A recusa é do apontamento inteiro, e o retorno é parcial. Basta uma disciplina recusada para que nada daquele apontamento seja gravado: as outras que vieram no mesmo pedido não se movem. Os demais apontamentos da lista seguem normalmente, e valem aqui as mesmas advertências de updateIssues — resposta 200, lista errors do GraphQL vazia, rejeições só em rejectedIssues. Percorra-a sempre.

Uma diferença de forma, se você reaproveita o tratamento de retorno do outro lote: aqui a rejeição é plana e traz a mensagem{ "id": 1240, "code": "…", "message": "…", "status": 403 } —, em vez do objeto error aninhado e sem mensagem de updateIssues. A resposta também é tipada: updatedIssues é uma lista de apontamentos, com os mesmos campos de qualquer consulta.

Criação em lote e cópia — as duas restrições que surpreendem

⚠️ createIssues grava imagens, mas não passa pelo serviço de upload. O que você manda em images é persistido como veio, sem tratamento. Na prática:

  • Só a URL já hospedada tem efeito — use o fluxo de URL assinada, suba o arquivo antes e informe a URL pronta.
  • Envio de arquivo direto é ignorado (originalFile, markedUpFile, markedUpThumbFile).
  • Nenhuma miniatura é gerada, e a versão com marcações cai de volta para a original.
  • cover: true é descartado — o lote nunca define a capa do apontamento.

Se você precisa de miniatura ou de capa, crie o apontamento pelo caminho individual (createIssue), ou crie em lote e anexe as imagens depois por updateIssue — os dois passam pelo pipeline completo.

O input do lote também não aceita viewpoints, as referências de documento, as referências de documento CCode nem as de modelo.

🔴 copyProjectIssues exige papel de gestão no projeto de destino — não basta a permissão de participar que serve para todo o resto da escrita. Na origem basta visualizar. É a única mutation de apontamento que sai do padrão, e a recusa vem como erro de permissão genérico.

Duas observações que evitam desenho errado

  • Não existe operação em lote heterogênea. updateIssues aplica o mesmo conjunto de valores a todos os identificadores da lista. Alterações diferentes para apontamentos diferentes são chamadas diferentes.
  • Lote não é agrupamento de operações. Uma mutation em lote é uma operação GraphQL com muitos itens, e é recomendada. Enviar um array de operações numa chamada HTTP é outra coisa, e é recusada (§5).

Não atropelar quem está editando

Não há trava. Duas escritas concorrentes sobre o mesmo apontamento não conflitam: a última vence, em silêncio. O que existe é o material para você detectar a concorrência antes de escrever, sem custo nenhum:

  • editedAt e editedByUserId marcam quem tocou por último e quando, e avançam em qualquer edição.
  • Leia o apontamento, guarde o editedAt, e compare-o no momento de escrever. Se mudou, alguém editou no intervalo — decida se sobrescreve, mescla ou descarta.

Reduzir a janela é mais eficaz do que detectá-la: envie a alteração no momento em que ela acontece, em vez de acumular numa fila que drena de tempos em tempos. A janela de conflito é proporcional ao tempo que a alteração espera enfileirada; enviando na hora, ela quase desaparece — e boa parte da leitura prévia deixa de ser necessária.

Criar e editar comentário

mutation CreateComment {
createProjectIssueComment(
projectId: 42
issueId: 1234
message: "Reparo executado em 20/01, com registro fotográfico."
visibility: "public"
) {
id issueId message visibility createdAt createdByUserId
images { id title original markedUp markedUpThumb }
}
}

Obrigatórios: projectId, issueId, message, visibility. Demais argumentos: images: [IssueCommentImageInput], mentionedUsers: [Int], groupId: Int.

mutation UpdateComment {
updateProjectIssueComment(projectId: 42, commentId: 987, message: "Texto corrigido.") {
id message visibility editedAt editedByUserId
}
}

Obrigatórios: projectId, commentId. Demais argumentos: message: String, visibility: String, images: [IssueCommentImageInput], mentionedUsers: [Int], groupId: Int. Campos omitidos permanecem inalterados — com uma exceção destrutiva:

🔴 Omitir mentionedUsers apaga todas as menções do comentário. É o único campo em que a regra "campo omitido é preservado" não vale. Quem edita só o texto e não reenvia a lista perde as menções, sem erro e sem aviso — e não há como recuperá-las pela API, porque o tipo Comment não expõe as menções na leitura. Reenvie sempre a lista completa de mentionedUsers, mesmo quando ela não mudou.

⚠️ images na edição é aditivo: os itens enviados são acrescentados. Para remover uma imagem, mande o item com { id, deleteImage: true }.

Input de imagem do comentário:

input IssueCommentImageInput {
id: Int
type: String
title: String!
cover: Boolean
deleteImage: Boolean
viewpoint: JSON
markUp: JSON
index: Int
original: String
originalFile: Upload
markedUp: String
markedUpFile: Upload
markedUpThumb: String
markedUpThumbFile: Upload
}

⚠️ Criar um comentário não altera a data de atualização do apontamento. Se a sua integração escreve comentários e também sincroniza, ela não vai reencontrar o próprio comentário pela janela de apontamentos — só pela janela de comentários (§7).

Imagens em três passos

O binário nunca passa pela API. O caminho é sempre: pedir uma URL assinada, subir o arquivo diretamente no armazenamento, e referenciar a URL final na mutation.

Passo 1 — pedir as URLs:

mutation UploadSignedUrls($projectId: Int!, $filenames: [String!]!) {
createProjectIssueFileUploadSignedUrls(projectId: $projectId, filenames: $filenames)
}

Retorno: JSON — uma lista de { signedUrl, contentType }, na mesma ordem de filenames. Lista vazia devolve [].

⚠️ O casamento é por POSIÇÃO, nunca por nome. Do nome o servidor usa só a extensão — a chave do objeto é um identificador que ele gera. Dois arquivos de mesmo nome são dois itens distintos, e ler o retorno por nome entrega o link de um arquivo a outro: o anexo abre o conteúdo errado, sem erro nenhum.

CaracterísticaValor
Teto por chamada50 nomes
Acima do tetorecusa com UPLOAD_URL_BATCH_LIMIT_EXCEEDED, e nenhuma URL é emitida
ArmazenamentoGoogle Cloud Storage (storage.googleapis.com)
Validade da URL1 hora
Método aceitoPUT
Balde de taxaPróprio (user-upload-url), separado do de mutations (§5)

A versão de um nome só, createProjectIssueFileUploadSignedUrl(projectId, filename), continua existindo e devolve o objeto direto em vez da lista. Ela está obsoleta — resolve pelo mesmo caminho da plural, que atende um nome ou muitos.

⚠️ Não existe campo key nem fileUrl no retorno. A URL final do arquivo é a signedUrl sem a query string — corte tudo a partir do ?.

Passo 2 — subir o arquivo:

PUT {signedUrl}
Content-Type: {contentType devolvido no passo 1}
Corpo: os bytes do arquivo

⚠️ Não acrescente cabeçalhos ao PUT. Em particular, não envie x-goog-acl: ele não faz parte da assinatura, e a requisição passa a ser recusada pelo armazenamento — com um erro que vem do Google, não do Construflow, e que confunde o diagnóstico. A leitura pública do arquivo vem da política do bucket, não de permissão por objeto. Envie o Content-Type que a mutation devolveu e nada mais.

Passo 3 — referenciar na mutation:

Use a URL final (sem query string) ao criar ou alterar o apontamento ou o comentário — e preencha original e markedUp com ela, deixando markedUpThumb de fora:

images: [{ title: "fachada-leste.jpg", original: "<url final>", markedUp: "<url final>" }]

🔴 A miniatura só é gerada se markedUp estiver preenchido. Quem faz os três passos e envia original fica sem miniatura, em silêncio: nenhum erro, nenhum aviso, e a imagem aparece sem capa e sem versão reduzida — a interface passa a carregar o arquivo em tamanho cheio onde deveria haver miniatura. É a armadilha do fluxo de URL assinada, e ela não existe no caminho por multipart (originalFile), onde o servidor preenche markedUp sozinho.

Duas formas de fazer certo, e a primeira basta:

  • Repita a URL em markedUp e omita markedUpThumb — o servidor baixa o arquivo, gera o PNG (≤200×200, proporção preservada) e o publica.
  • Ou suba a sua própria miniatura por uma segunda URL assinada e preencha markedUpThumb.

⚠️ A URL precisa ser a URL pública final do arquivo — a signedUrl sem a query string. Se você passar a URL assinada inteira, com a query string, a geração da miniatura falha em silêncio e você volta ao mesmo desfecho.

Se você subiu duas versões do arquivo — o original e uma cópia com marcações —, aí sim original e markedUp recebem URLs diferentes, e a miniatura é gerada a partir da versão com marcações.

Peça as URLs do gesto inteiro numa chamada só, e sob demanda. A URL vale uma hora e o balde de emissão é pequeno, então o que não se faz é pré-alocar para depois: pedir N URLs em N chamadas paralelas é o que estoura o balde, e pedir antes da hora as desperdiça.


12. Importação de verificação de modelo (BCF)

A importação de um arquivo .bcf cria (ou atualiza) apontamentos no projeto a partir dos tópicos do arquivo. É o caminho recomendado para trazer o resultado de uma verificação de modelo BIM para dentro do Construflow — não há endpoint melhor.

São três passos, e os dois primeiros precisam acontecer em sequência imediata.

Passo 1 — abrir o processo de importação

POST /bcf-server/bcf/import-process/{projectId}
Content-Type: application/json

Corpo:

ParâmetroObrigatórioValoresO que faz
visibilitysimcreator · project_management · publicVisibilidade com que os apontamentos do arquivo entram no projeto. ⚠️ Não use group — ver abaixo
commentVisibilityse o arquivo tiver comentáriosmesmos valores acimaVisibilidade com que os comentários do arquivo entram
nonResponsibleDisciplinesStatusnão (padrão participate)participate · followComo entram as disciplinas listadas em Labels que não são a responsável
includeBcfTopicNumbernão (padrão false)booleanoPrefixa o título com o número do tópico no arquivo (BCF#12)

Resposta: 200 com { "signedUrl": "..." }.

🔴 visibility: "group" faz falhar TODOS os tópicos. A importação não tem como dizer de que grupo se trata — não existe parâmetro para isso —, então a verificação de permissão nunca passa e cada tópico é recusado com erro de permissão. Não há caminho em que funcione. Use creator, project_management ou public (§6).

⚠️ commentVisibility não é um campo do BCF — é uma decisão de importação que o arquivo não carrega. O padrão BCF não tem noção de visibilidade, então é aqui que se define quem enxerga os comentários importados. Se o arquivo trouxer comentários e esse parâmetro não for enviado, cada comentário falha individualmente — os apontamentos entram normalmente, e você só descobre a perda no e-mail de resultado. Se há qualquer chance de o arquivo ter comentários, envie sempre.

⚠️ commentVisibility não aceita os mesmos valores da visibilidade do apontamento, e o que você pede não é o que necessariamente é gravado. Três diferenças:

  • group é descartado na importação. Pedi-lo não é erro: o comentário entra com a maior visibilidade abaixo de group que a sua permissão permitir — na prática, creator.
  • O valor funciona como teto, e é rebaixado em silêncio conforme a permissão de quem importa e a visibilidade do apontamento. Um usuário que só participa do projeto pedindo public num apontamento public recebe comentários creator — sem erro, sem aviso.
  • E, quando nenhuma visibilidade permitida cabe abaixo do teto pedido, o comentário entra com a menor visibilidade disponível acima dele — ou seja, o teto pode ser furado para cima. Se a confidencialidade importa, não confie no parâmetro: dimensione a permissão do usuário da integração pela visibilidade que você quer de fato, e confira a visibility dos comentários depois da importação.

Passo 2 — subir o arquivo

PUT {signedUrl}
Corpo: os bytes do arquivo .bcf

⚠️ A janela é de 200 segundos a partir do passo 1. O processamento começa a procurar o arquivo assim que o processo é aberto, e desiste depois desse tempo. Suba o arquivo imediatamente após receber a URL — não trate os dois passos como estágios de filas separadas, nem enfileire a abertura do processo para "subir depois".

🔴 A URL vive muito mais que a janela, e é aí que se perde o arquivo. A URL assinada vale quase sete dias; a janela de processamento é de 200 segundos. Subir o arquivo depois desses 200 segundos tem sucesso — o armazenamento aceita o PUT e devolve 200 — e não faz nada: o processamento já desistiu, o arquivo fica órfão no armazenamento, nenhum apontamento é criado, nenhum e-mail é enviado. O seu lado vê um upload bem-sucedido e uma importação que nunca aconteceu. Um PUT com sucesso não é confirmação de importação.

Passo 3 — o resultado

O resultado chega por e-mail ao usuário dono da credencial: quantos apontamentos foram criados, quantos foram atualizados, e a lista de tópicos que falharam com o motivo de cada um.

⚠️ Não há consulta de andamento nem de resultado por API. O e-mail é a única saída. Duas consequências que precisam entrar no seu desenho:

  • Se a credencial pertence a um usuário de serviço cuja caixa de entrada ninguém lê, a importação é cega — ela pode falhar inteira sem que você fique sabendo. Aponte esse endereço para uma caixa monitorada, ou trate o e-mail programaticamente.
  • Uma importação sem retorno não é sinal de sucesso. Confirme o efeito pela API, consultando os apontamentos do projeto pela janela de data (§7).

⚠️ Abrir o processo não valida nada. O passo 1 devolve 200 e uma URL assinada mesmo que o projeto não exista, esteja inativo, ou a credencial não tenha acesso a ele. Toda a validação acontece no processamento, e chega por e-mail.

A permissão é conferida em dois momentos diferentes, e a diferença muda o desfecho:

O que faltaQuando é conferidoDesfecho
Permissão de visualizar o projetologo no início, antes de qualquer tópico🔴 A importação inteira morre. Nenhum tópico é tentado, e o e-mail traz um único erro genérico, sem identificar tópico nenhum
Permissão de participar do projeto (ou projeto inativo)na gravação de cada apontamentoOs tópicos falham um a um, cada um com o seu motivo no e-mail

Um e-mail de resultado sem nenhum tópico identificado é o sinal de falha global — comece checando o acesso da credencial ao projeto.

Comportamento da importação

  • Versões suportadas: BCF 2.1 e 3.0.

  • Reimportar atualiza, não duplica. A identificação é pelo identificador (GUID) do tópico: se já existe um apontamento com aquele identificador no projeto, ele é atualizado. É o comportamento desejado quando a verificação de modelo é rodada de novo sobre o mesmo escopo.

  • ⚠️ A reimportação sobrescreve título, descrição, situação, prioridade, categoria, fases, locais, disciplinas — e, junto com as disciplinas, a situação de cada uma, o prazo e a data de conclusão delas —, mais o prazo do apontamento e a fase de resolução. Preserva visibilidade, etiquetas e as imagens já existentes. Na prática: entre duas verificações, o arquivo .bcf é a fonte da verdade desses campos — ajuste feito à mão no Construflow nesse intervalo é substituído sem aviso.

    O que isso custa, em concreto: uma disciplina que a equipe moveu para doing ou review volta para a situação que o arquivo diz; o prazo do apontamento é recalculado a partir das disciplinas do arquivo — e zerado, se nenhuma delas trouxer prazo (regra 5); e a fase de resolução é forçada a nulo, porque o arquivo traz o apontamento como ativo. Quem usa BCF em ciclos deve tratar o Construflow como espelho do arquivo nesses campos, não como o lugar onde o andamento é registrado.

  • ⚠️ Locais e disciplinas são substituição total, não fusão. O que foi acrescentado à mão no Construflow e não está no arquivo é removido. Não há como manter um local ou uma disciplina extra entre reimportações.

  • Comentários não são duplicados na reimportação (também identificados por GUID). Em compensação, comentário editado no arquivo não atualiza o que já entrou.

  • ⚠️ O identificador do tópico é único em toda a plataforma. O mesmo GUID não pode existir em dois projetos. Gerador de BCF que reaproveita identificadores entre escopos faz aquele tópico falhar no segundo projeto — só ele, e a importação segue com os demais. O apontamento do primeiro projeto não é tocado.

Viewpoints não são sobrescritos

Os viewpoints são a exceção da lista acima, e o comportamento deles é o menos intuitivo da importação. São quatro casos, decididos pelo identificador (GUID) de cada viewpoint:

No arquivoO que acontece
Os mesmos identificadores de antes, na mesma ordemNada muda. Nem sequer há gravação
Identificador que já existe no apontamentoO conteúdo antigo é mantido. Câmera, corte e imagem alterados no arquivo são ignorados
Identificador novoÉ acrescentado, com o conteúdo do arquivo
Nenhum viewpoint no tópicoApaga todos os viewpoints existentes

🔴 Não existe "atualizar um viewpoint". Reposicionar a câmera de um viewpoint já importado e reimportar o arquivo não tem efeito — o GUID é o mesmo, então o conteúdo antigo prevalece, em silêncio. Para trocar o conteúdo, o gerador de BCF precisa emitir o viewpoint com um GUID novo. E cuidado com o caminho inverso: um tópico que perdeu o viewpoint no arquivo apaga os que existiam no Construflow.

⚠️ O identificador lido é o Guid do arquivo de visualização (.bcfv), não o do tópico. Gerador que não emite esse atributo entrega viewpoints sem identificador, e aí o servidor sorteia um novo a cada importação — o conjunto passa a ser substituído sempre, que é o oposto do que a tabela acima descreve. Se você gera o arquivo e quer o comportamento previsível, emita o Guid do .bcfv e mantenha-o estável entre rodadas.

Como os campos do arquivo são interpretados

Campo do BCFVira no ConstruflowComo casaConfira contra
PriorityPrioridadeValor em português, exato — ver o alerta abaixo
TopicStatusSituaçãoValor conhecido, exato — ver abaixo
AssignedToDisciplina(s) responsável(is)Pela abreviação da disciplinacadastro do projeto
Labels sem prefixoDisciplinas participantesPela abreviação da disciplinacadastro do projeto
Labels com prefixo ^LocaisPelo caminho hierárquico de abreviações, separado por /cadastro do projeto
TopicTypeCategoriaPelo número antes do hífencatálogo global
StageFasePela abreviação antes do hífencatálogo global
DueDatePrazo das disciplinas de AssignedTo
TitleTítuloUm prefixo no formato exato NNNN - (4 ou 5 dígitos, espaço, hífen, espaço) é removido: 12345 - Vazamento entra como Vazamento. Fora desse formato o número permanece no título — 123 - …, 123456 - …, 1234-Título e espaçamento duplo não são tocados

Valor que não casar faz o tópico falhar, com o motivo no e-mail — e os detalhes de cada campo abaixo são o que separa uma importação limpa de cinquenta falhas descobertas depois do fato. O casamento é sensível a maiúsculas e acentos em todos os campos.

🔴 Priority é a armadilha mais provável de todas. Só três valores são aceitos, em português, escritos exatamente assim:

No arquivoVira
Altahigh
Médiamedium
Baixalow
(ausente)medium

High, Normal e Low — que é o que um gerador de BCF padrão emite — falham o tópico. Não são ignorados, não caem num valor padrão: derrubam o tópico inteiro, com Priority "High" does not exists no e-mail. Um arquivo BCF gerado por qualquer ferramenta que não seja o próprio Construflow tende a falhar todos os tópicos por causa disto. Se você gera o arquivo, traduza a prioridade antes de exportar; se não gera, omita o campo (o que dá prioridade média) ou reescreva o arquivo. alta minúsculo e ALTA também falham; espaço em volta do valor é tolerado.

⚠️ TopicStatus é a exceção à regra "valor não reconhecido falha o tópico". Situação desconhecida não falha: o tópico entra em silêncio como ativo. Reconhecidos: Ativo, Active, active → ativo; Resolvido, Resolved, Closed, resolved → resolvido; Reprovado, reproved → reprovado. Repare nas ausências — Reproved (inglês, capitalizado) e closed (minúsculo) não estão na lista e viram ativo, sem nenhum aviso no e-mail. Confira o resultado depois de importar; o e-mail não vai contar.

⚠️ TopicType casa pelo NÚMERO, não por sigla. O formato é o que o próprio Construflow exporta: o id numérico da categoria, um hífen e o nome — 03 - Interferência ou divergência entre disciplinas. Só o que vem antes do hífen é lido, e é lido como número: 3 e 03 sozinhos também casam. ARQ - Arquitetura não casa com nadaARQ não é número, e o tópico falha. Campo ausente equivale a 00.

⚠️ AssignedTo aceita várias disciplinas, separadas por vírgula — e ignora e-mails em silêncio. ARQ, EST atribui as duas. Qualquer entrada que contenha @ é descartada sem aviso — e é exatamente ali que o padrão BCF manda pôr o e-mail do responsável. Consequência: um arquivo BCF padrão, com AssignedTo=joao@empresa.com, entra sem responsável explícito; nesse caso o sistema promove as disciplinas de Labels a responsáveis, e se também não houver Labels o tópico falha com Responsible discipline is required. Abreviação que não existe no projeto (e não contém @) falha o tópico.

⚠️ DueDate só vira prazo quando o responsável veio de AssignedTo. Quando a responsabilidade é promovida a partir de Labels, o DueDate é silenciosamente perdido — e, como o prazo do apontamento é derivado das disciplinas (§11), o apontamento entra sem prazo.

⚠️ Local (Labels com ^) casa pelo CAMINHO inteiro, não pela última sigla. O valor é a sequência de abreviações da raiz até o nó, separada por /: ^TORRE/PAV2, não ^PAV2. Esse caminho é o campo abbreviationTree de cada local, que vem no cadastro do projeto em project { locals }, dentro de flatten (§6) — leia de lá, não monte à mão. Sem espaço depois do ^ e sem espaço em volta da barra.

🔴 Tópico sem nenhum local vira ^ND. Se nenhum label do tópico começa com ^, o sistema injeta ^ND — e então exige que exista um local com abreviação ND no projeto. Projeto nenhum nasce com esse local. Se você importa arquivos cujos tópicos nem sempre trazem local, crie um local ND no projeto antes, ou todos esses tópicos falham com Local "ND" does not exists.

⚠️ Categoria e fase passam por dois portões, e o primeiro é o catálogo GLOBAL. O casamento é contra o catálogo global da plataforma — nele, id e abreviação existem independentemente do projeto. Só depois, ao gravar o apontamento, o projeto é consultado: valor que casou globalmente mas está inativo neste projeto ainda falha o tópico, agora com CATEGORY_MUST_BE_ACTIVE / PHASE_MUST_BE_ACTIVE. project { categories { id name active } } e project { phases { … } } respondem aos dois portões de uma vez: eles trazem o catálogo global inteiro, anotado com o active deste projeto (§6).

Antes de uma importação grande, confira as abreviações de disciplina e os caminhos de local do arquivo contra o cadastro do projeto, e as categorias e fases contra project { categories } / project { phases }, filtrando por active (§6) — é o que evita descobrir cinquenta falhas depois do fato.

Armadilhas da importação

Além das acima, quatro falhas que não se anunciam:

  • 🔴 Subir o arquivo depois dos 200 segundos tem sucesso e não faz nada. A URL vale quase sete dias, mas a janela de processamento é de 200 segundos: o PUT é aceito, o arquivo fica órfão, e não há erro nem e-mail. Ver passo 2.
  • 🔴 Versão de BCF não suportada derruba a importação inteira — não tópico a tópico. O e-mail traz um erro único, e nenhum apontamento é criado. Só 2.1 e 3.0 são aceitos.
  • ⚠️ Identificador de projeto não numérico na rota não é validado na abertura. A importação abre com sucesso e morre depois, com um e-mail genérico que não diz que o problema era o projeto.
  • ⚠️ Comentário só é importado se o apontamento entrou. Tópico que falhou leva os comentários dele junto — e o e-mail sinaliza a perda, mas não lista comentário por comentário. E comentário vazio é descartado em silêncio, sem aparecer no e-mail de forma nenhuma.

13. Checklist de integração

Recapitulação acionável. Cada item aponta a seção que explica o porquê.

Antes da primeira chamada

  • Criar um usuário dedicado à integração, com as permissões de projeto que ela precisa e nada além — a chave enxerga exatamente o que esse usuário enxerga. §2
  • Emitir a Chave de Acesso e guardar o segredo — ele só é exibido uma vez. §2
  • Enviar um User-Agent identificável em toda requisição. §5
  • Desligar o agrupamento automático de operações do seu cliente GraphQL (BatchHttpLink e equivalentes). §5
  • Tratar 200 como possível erro: verificar errors antes de ler data, sempre — inclusive nas recusas de formato de filtro, que também vêm com 200. §4
  • Implementar a política de retry por status: 400 nunca; 429 pelo Retry-After; 5xx com backoff e jitter. §4
  • Tratar 429 pelo status e pelos cabeçalhos, nunca pela forma do corpo — a recusa mais provável (a portaria) vem sem a lista errors. §5
  • Colocar um limitador global de ~4,5 requisições por segundo e no máximo 4 simultâneas (2 a 4 processos em paralelo, nunca mais). §5

Na carga inicial

  • Enumerar os projetos por loggedUserData { projects(permission: "view") } — sem o argumento a lista traz só onde a credencial escreve — e guardar o status, porque ela inclui projeto inativo. §6
  • Carregar o cadastro de cada projeto numa chamada só e guardá-lo do seu lado. §6
  • Rodar as consultas do passo 1 e do passo 2 sem janela de data, projeto a projeto, paginando até o fim — mantendo o includeDeleted: true no passo 1, que é o que recupera as exclusões antigas que janela de data nenhuma alcança. §7
  • Gravar por upsert idempotente — bordas de janela inclusivas repetem o item da borda. §7
  • Saber que a carga não traz comentário de apontamento excluído — esse dado não existe pela API. §7

Em cada rodada

  • Reenumerar os projetos e comparar o conjunto de identificadores com o da rodada anterior — projeto que sumiu é perda de acesso, e não há outro sinal. §6 · §9
  • Usar como janela [início da rodada anterior, início desta rodada] — nunca o fim da anterior, nunca "de agora para trás". §7
  • Enviar as datas com fuso explícito (Z ou -03:00). §7
  • Rodar as duas consultas, apontamentos e comentários. Comentário novo não move a data do apontamento. §7
  • Enviar filter.includeDeleted: true no passo 1 — sem ele a exclusão de apontamento é invisível, sem erro nenhum. §10
  • Buscar detalhe apenas da união dos dois, excluindo o que veio marcado como excluído — e em lotes por filter.ids, se você não espelha o histórico. §7
  • Passar first explícito, ajustado à seleção — até 200 só com escalares, da ordem de 50 com relacionamentos expandidos —, e devolver o cursor exatamente como veio. §5 · §10
  • Iterar até hasNextPage ser false — a última chamada vazia é esperada. §10
  • Tratar deletedAt preenchido como remoção nas duas fontes — e ramificar por ele antes de ler qualquer outro campo do apontamento, que no excluído chega vazio. §9 · §7
  • Manter a seleção enxuta: sem notifications, sem comments no detalhe, sem viewpoints e bimPins fora do fluxo BIM, e nunca modelReferences, que não existe na saída e derruba a consulta. §8

Periodicamente

  • Reler o cadastro de cada projeto — uma vez por dia é folgado — e conferi-lo contra os vínculos guardados: etiqueta ou local apagado no projeto não gera sinal nos apontamentos. §6 · §9
  • Agendar a recarga total por projeto (mensal, por exemplo) e reconciliar por conjunto de identificadores. §9
  • Investigar todo projeto ativo cuja rodada voltou vazia — pode ser perda de acesso, que não produz erro. §9

Ao escrever

  • Validar os identificadores de local, etiqueta e disciplina contra o cadastro do projeto, e os de categoria e fase contra project { categories } / project { phases } filtrando por active, antes de enviar. §11 · §6
  • Na criação, sempre enviar locals e disciplines não-vazios — omitir cria o apontamento e depois estoura, deixando um registro órfão. §11
  • Enviar só os campos que mudaram — campo omitido é preservado. §11
  • Nunca mandar [] querendo dizer "não mexer": apaga em etiquetas e referências, e é recusado em locais e disciplinas. §11
  • Enviar resolutionPhase junto com status — o gatilho é enviar, não mudar — e manter status e disciplines coerentes entre si. §11
  • Ao enviar disciplines: a lista é o estado final, e cada item precisa repetir status, deadline e doneAt, senão o prazo é zerado e a data de conclusão é sobrescrita. remove: true não funciona na alteração individual. §11
  • Lembrar que o prazo do apontamento é derivado das disciplinas — enviar disciplinas sem prazo o apaga. §11
  • No lote, percorrer rejectedIssues: a mutation devolve 200 sem errors mesmo com apontamentos recusados. §11
  • Ao editar comentário, reenviar mentionedUsers sempre — omitir apaga todas as menções. §11
  • Comparar editedAt antes de sobrescrever — não há trava, a última escrita vence. §11
  • Imagens: URL assinada → PUT sem cabeçalhos extras → URL final sem a query string em original e em markedUp — sem markedUp não há miniatura. §11
  • Para criar apontamento com imagem, usar createIssue individual: a criação em lote não processa imagens. §11
  • Manter o de-para de situação, prioridade e visibilidade do seu lado — a API não traduz. §8

Ao importar BCF

  • Traduzir a prioridade para português (Alta / Média / Baixa) antes de exportar o arquivo — High / Normal / Low falham o tópico. §12
  • Emitir TopicType com o número da categoria antes do hífen (03 - …), não com sigla. §12
  • Emitir os locais como caminho completo de abreviações (^TORRE/PAV2), lido do abbreviationTree do cadastro. §12
  • Garantir que exista um local de abreviação ND no projeto, se algum tópico puder vir sem local. §12
  • Não contar com AssignedTo para e-mails: entrada com @ é descartada em silêncio. §12
  • Enviar commentVisibility sempre que o arquivo puder conter comentários — e conferir a visibilidade gravada, que pode ter sido rebaixada. §12
  • Subir o arquivo imediatamente após abrir o processo — a janela é de 200 segundos, e o PUT fora dela tem sucesso sem importar nada. §12
  • Apontar a credencial para uma caixa de e-mail monitorada: o resultado só chega por lá. §12
  • Conferir a situação dos tópicos depois de importar — TopicStatus desconhecido vira ativo em silêncio. §12
  • Tratar o Construflow como espelho do arquivo nos campos sobrescritos: situação das disciplinas, prazos e fase de resolução voltam ao que o arquivo diz. §12