Pular para o conteúdo principal

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

Este documento descreve o contrato público suportado para integrar com o Construflow: autenticação, limites de uso, consultas, sincronização, escrita e importação de verificação de modelo. Use apenas as operações, os campos e os valores documentados aqui. Comportamentos observados fora desse contrato não são garantia de compatibilidade e podem mudar sem aviso.

Consulte o changelog ou receba atualizações por e-mail.


1. Índice​

2. Introdução​

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§7
Ler o cadastro de cada projeto — disciplinas, situações de disciplina, locais, etiquetas, categorias, fases§7
Manter uma cópia sincronizada de apontamentos e comentários, inclusive exclusões§8
Consultar com o vocabulário completo de filtros§11
Criar e alterar apontamentos e comentários, anexar imagens§12
Importar uma verificação de modelo em BCF§13

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 §8.

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 (§3), e POST /bcf-server/bcf/import-process/{projectId}, a importação de verificação de modelo (§13).
  • Binário nunca trafega pela API: imagens sobem por URL assinada, direto no armazenamento (§12).

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 (§7) e o cadastro de cada projeto (§7), 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 (§11).
  2. A janela de data — as bordas são inclusivas e o início errado perde edições (§8).
  3. Exclusões — duas fontes independentes, e um conjunto de mudanças que não produz sinal nenhum (§10).
  4. Os limites de uso e o contrato de recusa (§6).
  5. As regras da alteração parcial na escrita, onde lista vazia apaga em uns campos e é recusada em outros (§12).

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


3. Autenticação​

A API autentica pelo OAuth 2.0: toda requisição leva um token de acesso no cabeçalho

Authorization: Bearer <access_token>

O token vem de um de dois fluxos, conforme quem chama:

O servidor de autorização, os dois fluxos e a renovação do token estão em Autenticação OAuth.

Testar a credencial​

Com o token em mãos, teste imediatamente, na rota que responde de quem é a credencial:

curl https://api.construflow.com.br/identity/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-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,
"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 ("conectada como Integração Empresa"). Ela responde quem é a credencial; o que ela alcança vem da lista de projetos (§7).

⚠️ O email de um usuário pode mudar, e o endereço antigo pode passar a outro usuário. O id e o guid continuam os mesmos. Use o id ou o guid como chave do usuário na sua base, nunca o email. Quando o endereço novo já era de outro usuário, os dois se unem: o que o outro criou passa a ter o id e o guid do usuário que trocou o e-mail, e os apontamentos e comentários voltam na sincronização incremental (§8). O outro continua existindo, sem projetos.

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 credencial opera com as permissões do usuário que a conectou. Tudo o que esse usuário puder ler ou alterar nos projetos pode ficar ao alcance da integração.

Crie um usuário dedicado para cada integração, conecte o script com ele, conceda somente os projetos e as permissões necessários e revise esse acesso periodicamente. Não use a conta pessoal de um colaborador nem uma conta administrativa: o vazamento do token teria o mesmo alcance dessa conta.

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

🔴 Trate o access token e o refresh token como credenciais de produção. Não grave em código, repositório, log, mensagem ou aplicação cliente. Em caso de exposição, interrompa a integração, revogue o acesso (Revogar), conecte de novo e investigue as chamadas realizadas com o acesso comprometido.

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, token inválido ou ausente não recebe 401. 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. Por isso o teste de credencial acima usa a rota REST, e todo cliente GraphQL deve verificar errors antes de ler data.

Se a sua integração parou de autenticar, verifique nesta ordem: o cabeçalho está sendo enviado, como Bearer; o access token está na validade — renove-o (Validade e renovação); o acesso não foi revogado — renovação recusada com invalid_grant pede conectar de novo.

Autenticação OAuth​

Esta parte descreve como obter, renovar e revogar o token: uma pessoa conecta um script à própria conta digitando um código, e um app age em nome de cada pessoa que o usa.

O servidor de autorização​

Os tokens OAuth são emitidos pelo servidor de autorização do Construflow, cujo identificador (issuer) é https://auth.construflow.com.br. Os endereços dele estão no documento de descoberta:

https://auth.construflow.com.br/.well-known/openid-configuration

O mesmo documento responde também em https://auth.construflow.com.br/.well-known/oauth-authorization-server. Leia os endereços de lá, em vez de montá-los à mão. Os exemplos deste documento trazem os valores atuais só para ficarem legíveis.

Campo do documento de descobertaPara quê
device_authorization_endpointPedir o código que conecta um script (Conectar um script pelo código)
token_endpointReceber os tokens e renová-los (Validade e renovação)
revocation_endpointRevogar o acesso (Revogar)
registration_endpointRegistrar um app automaticamente (Apps que agem em nome de quem os usa)
authorization_endpointLevar a pessoa à tela de permissão, no fluxo de app
jwks_uriAs chaves públicas que assinam os tokens

O único recurso aberto a integradores é https://api.construflow.com.br, escrito exatamente assim, sem barra no fim: é um identificador, não um endereço a chamar. O escopo é construflow.

O access token é um JWT de 15 minutos, emitido para esse recurso (aud). Trate-o como opaco: use o expires_in da resposta para saber quando renovar e não decodifique o token para decidir nada.

O que a permissão significa​

Conectar um script ou app é dar a ele acesso total e irrestrito à conta: ele passa a fazer tudo o que a pessoa pode fazer no Construflow, com as permissões dela — ver, criar, alterar e excluir. Não há acesso parcial.

Antes de permitir, a pessoa vê numa tela do Construflow:

  • o nome do app ou script;
  • o selo verificado ou não verificado pelo Construflow;
  • o endereço para onde o app leva a resposta;
  • a conta que está concedendo o acesso;
  • o aviso de acesso total e irrestrito.

App desconhecido funciona do mesmo jeito, no mesmo dia, marcado como não verificado. A permissão dada a um app fica lembrada por conta e app enquanto o nome dele e o endereço de retorno não mudarem. O script conectado por código pede a permissão a cada aprovação.

A pessoa revoga os acessos em Configurações → Aplicativos conectados; cada script aprovado é uma linha própria, revogável sozinha. Sair de todos os dispositivos, a troca e a redefinição de senha e a troca do e-mail do usuário encerram todos os acessos OAuth de uma vez.

Trocar a senha só se faz de dentro do Construflow: um token OAuth recebe recusa nessa ação.

⚠️ Para script de integração, conecte com um usuário dedicado, com só os projetos e as permissões necessários (O que a credencial enxerga). O script alcança exatamente o que a conta que o aprovou alcança.

Conectar um script pelo código​

O script usa o client_id público scripts, que não tem segredo: não há nada para emitir nem guardar além dos tokens. A conexão tem quatro passos:

  1. O script pede um código.
  2. O script mostra à pessoa o endereço e o código que vieram na resposta.
  3. A pessoa abre o endereço, entra na conta, confere que o código na tela é o mesmo do script e permite.
  4. Enquanto isso, o script consulta o servidor de autorização até receber os tokens.

O código tem 8 caracteres no formato XXXX-XXXX e vale por 10 minutos (expires_in). A consulta se repete a cada interval segundos (5). Cada aprovação é um acesso próprio, com uma linha própria em Aplicativos conectados.

Pedir o código:

curl -s https://auth.construflow.com.br/oauth/device/auth \
-d client_id=scripts \
-d scope=construflow
{
"device_code": "…",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.construflow.com.br/oauth/device",
"verification_uri_complete": "https://auth.construflow.com.br/oauth/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}

Mostre à pessoa o verification_uri_complete — ou o verification_uri e o user_code — exatamente como vieram. Não monte o endereço à mão.

Consultar até receber os tokens:

curl -s https://auth.construflow.com.br/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code="$DEVICE_CODE" \
-d client_id=scripts
{
"access_token": "eyJ…",
"expires_in": 900,
"token_type": "Bearer",
"refresh_token": "…",
"scope": "construflow"
}

Enquanto a conexão não se resolve, a consulta responde 400 com o corpo { "error": "<código>" }:

errorO que significaO que fazer
authorization_pendingA pessoa ainda não decidiuConsulte de novo depois de interval segundos
slow_downA consulta está rápida demaisSome 5 segundos ao intervalo e continue
access_deniedA pessoa cancelouPare. Para tentar de novo, peça outro código
expired_tokenO código venceuPeça outro código

O fluxo completo em Python, com requests:

import time
import requests

AS = "https://auth.construflow.com.br"
API = "https://api.construflow.com.br"
UA = {"User-Agent": "MeuScript/1.0 (contato@empresa.com)"}


def conectar():
r = requests.post(f"{AS}/oauth/device/auth",
data={"client_id": "scripts", "scope": "construflow"},
headers=UA, timeout=30)
r.raise_for_status()
pedido = r.json()
print(f"Abra {pedido['verification_uri_complete']} e confira o código {pedido['user_code']}.")
intervalo = pedido["interval"]
prazo = time.monotonic() + pedido["expires_in"]
while time.monotonic() < prazo:
time.sleep(intervalo)
r = requests.post(f"{AS}/oauth/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": pedido["device_code"],
"client_id": "scripts",
}, headers=UA, timeout=30)
if r.ok:
return r.json()
erro = r.json().get("error")
if erro == "authorization_pending":
continue
if erro == "slow_down":
intervalo += 5
continue
raise RuntimeError(f"conexão recusada: {erro}")
raise RuntimeError("o código venceu; peça outro")


def renovar(refresh_token):
r = requests.post(f"{AS}/oauth/token", data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": "scripts",
}, headers=UA, timeout=30)
r.raise_for_status()
return r.json() # traz um refresh_token NOVO: grave-o antes de usar o access_token


tokens = conectar()
me = requests.get(f"{API}/identity/users/me",
headers={**UA, "Authorization": f"Bearer {tokens['access_token']}"},
timeout=30)
print(me.json()["email"])

Com o token em mãos, toda chamada à API leva Authorization: Bearer <access_token>. Confira a conexão pelo teste de §3.

Validade e renovação​

O access token vale 15 minutos (expires_in). Renove antes de vencer, ou ao receber 401 numa rota REST ou o erro UNAUTHENTICATED no POST /graphql, que chega com status 200 (Quando a credencial não é aceita):

curl -s https://auth.construflow.com.br/oauth/token \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d client_id=scripts

A renovação devolve sempre um refresh token novo. O anterior continua aceito até o novo ser usado, e quem o reapresenta recebe o mesmo refresh token novo. Um refresh token mais velho que isso recebe 400 com invalid_grant, e o acesso continua valendo para quem tem o refresh token atual.

⚠️ Renove num lugar só. Deixe um processo só encarregado de renovar, e grave o refresh token novo antes de usar o access token novo.

O acesso dura enquanto for usado: cada renovação o estende por 30 dias. Depois de 30 dias sem renovar, é preciso pedir um código novo.

Refresh recusado — 400 com invalid_grant — pede que você releia o refresh token gravado. Se outro processo gravou um mais novo, use-o; se não, o acesso foi revogado — inclusive pela troca do e-mail do usuário — ou venceu: conecte de novo, com um código novo. Não tente de novo com o mesmo refresh token.

Revogar​

O script revoga o próprio acesso com o refresh token — ao ser desinstalado, ao trocar de máquina ou quando não precisar mais dele:

curl -s https://auth.construflow.com.br/oauth/token/revocation \
-d token="$REFRESH_TOKEN" \
-d token_type_hint=refresh_token \
-d client_id=scripts

A pessoa também revoga em Configurações → Aplicativos conectados (O que a permissão significa). Nos dois casos, o access token já emitido vale até vencer, no máximo 15 minutos; depois disso, nem ele nem a renovação funcionam.

Apps que agem em nome de quem os usa​

Esta parte é para quem constrói um app usado por várias pessoas — um assistente de IA, uma ferramenta web, um app de desktop —, em que cada pessoa conecta a própria conta. O app usa o fluxo de código de autorização (authorization code), com:

  • PKCE obrigatório, só com o método S256;
  • scope=construflow;
  • resource=https://api.construflow.com.br.

O app se identifica de uma de duas formas, sem cadastro manual no Construflow.

Registro automático (RFC 7591). O app faz um POST JSON em registration_endpoint, e a resposta traz o client_id dele:

curl -s https://auth.construflow.com.br/oauth/reg \
-H "Content-Type: application/json" \
-d '{
"client_name": "Meu App",
"redirect_uris": ["https://meuapp.com.br/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}'

Documento de metadados (Client ID Metadata Document). O client_id do app é a URL https de um documento JSON publicado pelo próprio app, com o mesmo client_id, o client_name e as redirect_uris:

{
"client_id": "https://meuapp.com.br/oauth/client.json",
"client_name": "Meu App",
"redirect_uris": ["https://meuapp.com.br/callback"]
}

O documento é servido na porta 443, por um host com endereço público, e responde em até 5 segundos com até 5 kB. O cache dele respeita o Cache-Control da resposta.

O endereço de retorno segue as mesmas regras nas duas formas:

  • https em qualquer host;
  • http só em loopback (localhost, 127.0.0.1, [::1]), em qualquer porta;
  • esquema próprio (meuapp://…) só com application_type: "native";
  • sem curinga.

O redirect_uri de cada pedido tem de ser exatamente um dos registrados; se não for, a recusa vem antes de qualquer tela. A tela de permissão aparece na primeira vez de cada pessoa e de novo se o nome do app ou o endereço de retorno mudarem.

⚠️ Client de registro automático que passa 90 dias sem nenhum acesso é apagado. Ao receber invalid_client, registre o app de novo.

Pedido com recurso diferente de https://api.construflow.com.br é recusado com invalid_target.

Os tokens do app seguem as regras de Revogar, com o client_id do próprio app no lugar de scripts, e o acesso dura enquanto for usado: cada renovação o estende por 30 dias. O app sem segredo — token_endpoint_auth_method none, o padrão quando o campo falta — renova como em Validade e renovação. No app que se autentica com segredo ou chave, o refresh token já trocado, se reapresentado, revoga o acesso, e a pessoa precisa conectar a conta de novo.


4. Endereço e rotas​

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

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

O servidor de autorização, que emite os tokens OAuth, fica em https://auth.construflow.com.br, e as rotas dele estão em Autenticação OAuth.

Este documento é a referência do esquema público suportado. Não dependa de introspecção nem de campos encontrados por tentativa: use somente as operações e seleções documentadas aqui.


5. 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 (§3):

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 limite429cabeç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.

A recusa FORBIDDEN de um projeto traz o id dele: extensions.projectId no POST /graphql e details.projectId nas rotas REST.

⚠️ 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 cinco, 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§6
PAGE_SIZE_EXCEEDEDfirst acima de 200code, reason, limit, observed§6
QUERY_COST_EXCEEDEDA consulta instancia objetos demaiscode, reason, limit, observed§6
BATCHING_NOT_SUPPORTEDVocê enviou um array de operações numa chamada sósó code§6
UPLOAD_REQUEST_INVALIDO corpo multipart não pôde ser lidosó code§6

⚠️ 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 §11.

A quarta forma: recusa na borda​

⚠️ Corpo grande demais pode devolver 403, não 413. A resposta não segue o envelope GraphQL e pode não trazer errors, extensions nem cabeçalhos de limite. Se você recebeu um 403 numa requisição volumosa, meça o corpo e reduza-o antes de tentar novamente.

Binário não deve ir embutido na requisição. Use a URL assinada (§12) 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 (§6)
5xxSimBackoff exponencial 2^n segundos, com jitter. Nunca em laço imediato

Avisos de obsolescência​

A resposta a uma chamada que usa um item obsoleto traz:

  • Deprecation: @<segundos Unix> — desde quando o item está obsoleto (RFC 9745);
  • Sunset: <data HTTP> — a data-limite, só quando existe, nunca antes da data do Deprecation (RFC 8594);
  • Link: <entrada do changelog>; rel="deprecation"; type="text/html" — um por item, apontando para a entrada daquele item, que diz o que está obsoleto e como migrar;
  • Warning: 299 - "<frase>" — uma frase curta por item, com o que está obsoleto e o que usar no lugar.

Com mais de um item na mesma resposta, Deprecation traz a data mais antiga, Sunset a mais próxima, e Link e Warning se repetem, um por item. A chamada funciona igual até a data-limite; a integração deve registrar esses cabeçalhos e tratá-los como alerta.


6. Limites de uso​

Os limites abaixo são parte do contrato da API, não uma meta de consumo. Dimensione abaixo dos tetos, limite o paralelismo e trate toda recusa. Requisições acima da faixa são rejeitadas; padrões persistentes que prejudiquem a plataforma podem levar a restrições adicionais para proteger os demais clientes.

Uma rodada parcialmente recusada não é uma rodada concluída. Registre falhas, respeite o tempo de espera informado e reconcilie o resultado antes de avançar o marcador da sincronização.

A tabela de limites​

LimiteValorAo exceder
Volume total por identidade, em cada contagem (toda requisição autenticada)40 / 10 s e 80 / 60 s429 user-envelope
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
Arquivo enviado por multipart (campos Upload)10 MB cada413 UPLOAD_FILE_SIZE_EXCEEDED
Arquivo enviado pela URL assinada10 MB a imagem PNG ou JPEG, 5 MB o restoUPLOAD_FILE_SIZE_EXCEEDED na mutation que grava o endereço
Arquivos por chamada em multipart500413 UPLOAD_FILE_COUNT_EXCEEDED

Os limites valem para o token OAuth de script e de app (Autenticação OAuth), cada um na contagem dele (Como os limites se combinam).

Como os limites se combinam​

As janelas de 10 e de 60 segundos valem ao mesmo tempo, e basta uma delas estourar para a requisição ser recusada. O limite (user-envelope) conta toda requisição autenticada: consultas, alterações, emissão de URL assinada, importação de BCF e as demais rotas da plataforma. Não há recorte por tipo de operação — uma consulta e uma mutation pesam igual na mesma conta, e é essa conta única que a integração precisa dimensionar.

As cotas são agregadas por identidade, e a contagem depende de quem chama:

Quem chama pelo OAuthContagem
App verificado pelo Construflow (O que a permissão significa)uma só dele, por pessoa
Script conectado pelo código e app não verificadouma só, dividida entre todos eles, por pessoa

Todas as contagens seguem a tabela de limites. Dois scripts da mesma pessoa continuam dividindo a contagem dos não verificados, e chamadas distribuídas em várias máquinas não mudam nada. Use identidades separadas apenas quando elas representarem integrações realmente independentes, cada uma com o seu usuário dedicado e o seu menor conjunto de permissões.

Emissão de URL assinada entra no volume total como qualquer outra chamada. Peça as URLs do gesto inteiro em uma chamada (até 50 nomes), sob demanda. Não abra uma chamada paralela por arquivo.

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), que continuam sendo a forma recomendada de escrever muitos apontamentos: elas são uma operação com muitos itens (§12).

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. Consulta acima do teto é recusada e deve ser simplificada, nunca repetida sem alteração.

Página máxima de 200. Passe first sempre de forma explícita, em toda consulta paginada. Não dependa do valor padrão: sem first, a resposta pode ser maior que a página recomendada e consumir mais objetos do que a integração dimensionou. Use até 200 somente para seleções escalares e reduza a página quando expandir relacionamentos.

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 (§9).

O envelope de recusa por taxa​

Toda recusa por taxa traz o status 429, os mesmos sete cabeçalhos e um corpo plano — os cinco campos na raiz, sem a lista errors:

HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Policy: "burst";q=40;w=10, "sustained";q=80;w=60
RateLimit: "burst";r=0;t=3, "sustained";r=0;t=12
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785000060
X-RateLimit-Reason: user-envelope
Content-Type: application/json; charset=utf-8
{
"message": "You have reached the request rate limit",
"code": "REQUEST_RATE_EXCEEDED",
"reason": "user-envelope",
"retryAfter": 12,
"policy": "\"burst\";q=40;w=10, \"sustained\";q=80;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 limite 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 só 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 limite estourou, e os dois pedem correções diferentes: user-envelope pede ritmo menor; credential-concurrency, menos chamadas abertas ao mesmo tempo. Reduzir o ritmo não resolve recusa por simultaneidade.
  • Os mesmos campos também vêm no corpo (reason, retryAfter, policy). Mesmo assim, trate o cabeçalho Retry-After como a referência para o tempo de espera.

🔴 Integrações devem rodar no servidor, nunca no navegador. Não exponha o access token nem o refresh token em JavaScript, aplicativo distribuído ao usuário ou qualquer ambiente em que a credencial possa ser inspecionada.

Os valores documentados de X-RateLimit-Reason são:

RazãoO que estourou
user-envelopevolume total da sua identidade, na contagem de quem chama
credential-concurrencytrabalho simultâneo da sua identidade

O corpo da recusa por simultaneidade tem a mesma forma, com os valores daquele limite:

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

⚠️ A recusa por taxa não vem no envelope GraphQL. Ela é aplicada antes de a requisição chegar ao GraphQL, então não há errors nem extensions no corpo — nem quando a chamada recusada era um POST /graphql.

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 recusa nenhuma e entra em laço.

O teto de simultâneas conta requisições em voo, não por minuto. Mantenha no máximo 2 requisições abertas ao mesmo tempo e trate credential-concurrency mesmo assim: tempo de resposta e outras chamadas da mesma identidade podem consumir parte da margem.

Dimensionamento​

Dimensione pela taxa, e mantenha o paralelismo baixo. O teto sustentado é de 80 requisições por minuto — cerca de 1,3 por segundo — e vale para tudo o que a integração faz, leitura e escrita na mesma conta. Use um limitador global de até ~1 requisição por segundo para toda a integração, com a margem servindo para a rajada de 10 segundos, e reduza o ritmo quando receber 429.

Na prática isso é 1 processo (ou conexão) trabalhando em sequência, no máximo 2: com duas 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.

Nesta taxa, ler item a item não escala. Dez mil apontamentos lidos um a um levam mais de duas horas; a listagem paginada traz os mesmos campos em dezenas de chamadas (§11), e o detalhe vem em lotes por filter.ids (§8). Desenhe a rotina pela listagem e reserve a leitura unitária para o que ela não entrega.

Paralelismo alto não acelera nada aqui: acima da faixa recomendada, o que você ganha em disparo perde em recusa, e a rajada 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 (§8). 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 (§7).
  5. Identifique-se. Envie sempre um User-Agent descritivo — MinhaIntegracao/1.2 (contato@empresa.com). Isso permite localizar rapidamente o responsável em suporte, incidentes e investigações de tráfego.

7. 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 (§3) — 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 vocabulário suportado é participate, view, manage e invites. Valide o valor antes de enviar: um valor fora do contrato pode produzir uma lista vazia sem explicar a causa. Se a descoberta voltou vazia, confira primeiro a grafia e as permissões do usuário dedicado.

⚠️ 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.

⚠️ Projetos de acesso público dos quais a credencial não é membro não são enumerados por esta consulta. Se a integração precisa operar num projeto, vincule o usuário dedicado explicitamente e não dependa de acesso público ou de um identificador obtido por fora da descoberta.

Em projeto inativo, as consultas de apontamento continuam respondendo normalmente, com uma exceção: os filtros pendencies e latePendencies devolvem lista vazia (§11).

⚠️ A lista é o recorte atual da credencial. Guarde o conjunto de identificadores de uma rodada para a outra e compare. Um projeto que desapareceu deve ser tratado como mudança de acesso e exige reconciliação; não continue publicando a cópia anterior como se ela ainda estivesse autorizada (§10).

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 disciplinesStatus — nã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 (§13).
  • 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 (§12).

⚠️ 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 (§9). O cadastro serve para outro propósito: preencher um seletor na sua interface e validar um valor antes de escrever (§12).


8. 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")Não use — o resultado depende do ambiente e não faz parte do contrato recomendado
BordasInclusivas nos dois lados
Janela invertida (From > To)Devolve lista vazia, sem erro
Valor inválidoFora do contrato; pode deixar o filtro sem efeito e ampliar a consulta

⚠️ Envie sempre o fuso explícito — Z ou -03:00. É a única forma que significa a mesma coisa dos dois lados. 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.

🔴 Valide a data localmente antes de cada rodada. Uma data fora do contrato pode deixar o filtro sem efeito e transformar a sincronização incremental numa varredura total do projeto. Se a rodada começou a trazer o acervo inteiro, interrompa-a e confira as datas antes de avançar o marcador.

⚠️ 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 (§6) — 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 §11.

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 (§11). 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 (§6). 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 markUp viewpoint }

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 §9.

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 (§9).

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 (§6) —, 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 (§11). 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 (§10). 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 §10 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 (§7).

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

Estratégia do passo 3ChamadasA ~1 req/s
Um a um1 + 100 + 800 = 901cerca de 15 minutos
Em lotes de 501 + 100 + 16 = 117cerca de 2 minutos

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 (§11). É esperado, e o laço converge.


9. 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 (§6) — campo escalar folha custa zero; objeto aninhado custa, vezes o tamanho da página.

Campos a evitar, e por quê​

CampoPor que evitar
notifications⚠️ Tem custo alto em listagens. Se você precisa do agregado, use notificationsSummary { count mentions last }
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 (§8)

⚠️ 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. 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.

A forma de escrita está em Viewpoints.

Deixe a consulta resolver os nomes​

Quando você pede disciplines { discipline { name } }, locals { local { ... } }, labels { label { ... } } ou createdByUser { name }, a API devolve o nome atual junto da relação. Peça esses nomes onde precisar deles, mas ajuste o tamanho da página ao número de objetos selecionados.

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

  • Aninhar na consulta — createdByUser { 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 projeto — project(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 (§12). 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 email não é chave de usuário. Ele pode mudar, e o endereço antigo pode passar a outro usuário; o id e o guid continuam os mesmos. Case o usuário pelo id, nunca pelo email, e releia o email como relê o nome. Quando o endereço novo já era de outro usuário, os dois se unem: o que o outro criou passa a ter o id do usuário que trocou o e-mail, e os apontamentos e comentários chegam pela sincronização incremental com o autor novo (§8).

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 §7 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 §7.

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 (§7) 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 (§12).

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.


10. 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✅§9 passo 1 — cai na janela de data
Apontamento excluído✅ só se você pedir§9 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✅§9 passo 2
Comentário excluído✅§9 passo 2 — vem na listagem com deletedAt e deletedByUserId
Disciplina, local ou etiqueta desvinculada pela alteração do apontamento✅A 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 §7

O que você NÃO detecta​

O que aconteceuDetecta?Por que não
Etiqueta ou local apagado no projeto❌Essa mudança não coloca os apontamentos vinculados na janela incremental. Releia o cadastro do projeto periodicamente e reconcilie os vínculos
Comentário de apontamento excluído❌A listagem de comentários não devolve comentários de apontamentos excluídos. Use o aviso de exclusão do próprio apontamento para encerrar o registro local
Conteúdo de um apontamento excluído❌A API devolve somente o aviso de exclusão. Preserve na sua cópia todo conteúdo que precise continuar disponível após a exclusão
Mudança de visibilidade ou de grupo que tira o apontamento do seu acesso❌O apontamento deixa de aparecer. Não trate ausência numa janela incremental, isoladamente, como autorização para conservar ou redistribuir a cópia anterior
Perda de acesso ao projeto⚠️ parcialO projeto pode desaparecer da descoberta ou responder erro de permissão. Compare a lista de projetos entre rodadas e investigue toda rodada vazia de um projeto que ainda parecia ativo

⚠️ 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 (§7) —, 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 (§6) — a recarga é pesada, mas não é urgente.


11. Consultas e filtros​

Referência completa da leitura. A sincronização de §8 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 (§6). 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 (§6). 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

Trate o cursor como valor opaco e devolva-o exatamente como veio. Cursor alterado ou fora de formato falha com INVALID_PAGINATION_CURSOR. Valide também first do seu lado como inteiro entre 1 e 200; valores fora desse contrato não são suportados.

⚠️ 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

🔴 A tabela acima é a lista fechada de filtros suportados. Valide as chaves e os tipos antes de enviar. Uma chave desconhecida ou escrita incorretamente pode não produzir uma recusa clara e pode transformar o que deveria ser um recorte numa varredura muito maior. Uma resposta 200 não prova que o filtro foi aplicado.

Cada listagem tem o seu próprio contrato. includeDeleted, por exemplo, pertence à listagem de apontamentos e não deve ser enviado à listagem de comentários do projeto, onde os comentários excluídos já fazem parte do retorno.

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 ou null)Todos os apontamentos visíveis ao usuário

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. Não envie valores fora da tabela: valide o enum do seu lado para impedir que um erro de grafia amplie a resposta.

⚠️ 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 (§9):

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. E uma terceira: o projeto precisa estar ativo. Em projeto inativo, pendencies e latePendencies devolvem lista vazia, sem erro, para qualquer perfil; reativado o projeto, voltam a trazer os apontamentos.

⚠️ 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 da credencial 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​

Envie um Int. Omitir a chave ou enviar null significa não aplicar esse filtro. Não dependa de coerção de strings nem envie valores fora do tipo documentado.

filter.ids — o filtro estrito​

⚠️ Este filtro é estrito. Envie uma lista de inteiros positivos. Item inválido ou valor que não seja lista recusa a consulta inteira com INVALID_ISSUES_IDS_FILTER; nenhum apontamento é devolvido.

  • ⚠️ [] significa conjunto vazio, não "sem filtro". Devolve zero apontamentos — o oposto de disciplineIds: [], que desliga o filtro. 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. Indexe pelo id de cada item e ordene do seu lado. O cursor continua sendo a única referência para avançar a paginação.
  • 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; duplicados são deduplicados. Valide a lista antes de enviar e não dependa de coerção ou tolerância a itens inválidos.
  • 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 §7.
  • Id que não pertence ao projeto, ou inexistente, não casa nenhum apontamento.
  • [], 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 não faz parte do contrato recomendado. Detalhe em §8.
  • updatedAtFrom maior que updatedAtTo devolve lista vazia, sem erro.
  • Valide a data antes de enviar. Um valor fora do contrato pode não produzir uma recusa clara e pode transformar a consulta numa 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 (§8).

  • Ligue o filtro somente com o booleano true. Não use strings ou números. Um valor fora do tipo documentado pode deixar a detecção de exclusão inativa sem produzir uma recusa clara.
  • 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 §9 passo 1 para a lista exata do que chega preenchido.
  • É honrado em todos os recortes por atributo — updatedAtFrom/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​

Valide filtros, cursores e paginação antes de chamar a API. Não use uma resposta bem-sucedida como validação do input: algumas entradas fora do contrato podem ser ignoradas, ampliar o recorte ou produzir erro genérico.

Duas recusas têm código próprio e podem ser reconhecidas 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 pode vir nulo enquanto data continua presente. Verifique errors antes de ler data; o código fica em errors[0].extensions.code e a mensagem em errors[0].message. O mesmo contrato de cursor vale para a listagem de comentários do projeto.

Outras entradas malformadas podem responder INTERNAL_SERVER_ERROR sem código específico. Não escreva tratamento baseado na mensagem interna: corrija o input e valide localmente os tipos da tabela de contrato. Profundidade, tamanho de página, preço e array de operações têm recusas próprias em 400 (§5, §6).

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 (§9). A seleção completa recomendada está em §9 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 (§8) — 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]. Não tente distinguir os dois casos por esta consulta.
  • 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 (§9 passo 1).

⚠️ Trate NOT_FOUND como resposta final. Não aplique retry a um identificador inexistente ou excluído.

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 (§8).

Argumentos:

ArgumentoContrato
first: IntTamanho da página. Máximo 200, e ajustado à seleção (§6). 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 (§9 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.


12. 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 (§5).

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.

title aceita até 255 caracteres, na criação e na alteração; acima disso o apontamento é recusado com ISSUE_TITLE_TOO_LONG (400). O título de imagem (IssueImageInput.title) segue o mesmo teto, mas é truncado em vez de recusado.

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

ArgumentoQuando é exigidoO que acontece se faltar
localssemprerecusa ISSUE_MUST_HAVE_LOCALS
disciplinessemprerecusa ISSUE_MUST_HAVE_DISCIPLINES
resolutionPhasesempre que status não for "active"recusa MISSING_RESOLUTION_PHASE

category e creationPhase podem ser omitidos: o apontamento é gravado com "Não definida" (id 0) no que faltar, em qualquer projeto — é o item que nenhum projeto desativa (§7). Enviar null dá no mesmo, inclusive na alteração: o campo nunca fica vazio.

🔴 Sempre envie locals e disciplines, ambos não vazios. Omitidos, nulos ou vazios, a criação é recusada sem gravar nada: ISSUE_MUST_HAVE_LOCALS para os locais, ISSUE_MUST_HAVE_DISCIPLINES para as disciplinas (regra 3). Não repita a criação às cegas depois de uma falha de rede: o apontamento pode ter sido gravado sem que a resposta chegasse, e a nova tentativa duplicaria o registro. Envie um guid próprio e reconcilie por ele antes de decidir qualquer nova tentativa.

⚠️ 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, category: Int, creationPhase: Int, deadline: Date, labels: [Int], images: [IssueImageInput], groupId: Int, guid: String, ccodeDocumentsReferences: [IssueCcodeDocumentReferenceInput], documentsReferences: [IssueDocumentReferenceInput], modelReferences: [ModelReferenceInput], viewpoints: [IssueViewpointInput], bimPins: JSON.

Os valores de status, priority e visibility estão em §9. Os identificadores de locals, labels, disciplines e groupId vêm do cadastro do projeto (§7); os de category, creationPhase e resolutionPhase vêm de um catálogo global, e precisam estar active neste projeto (§7). 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 # obsoleto
viewpoint: JSON
markUp: JSON
index: Int # obrigatório
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.

⚠️ images na edição é a lista completa das imagens: a gravada que não vier é removida, e images: [] remove todas; sem o campo, nada muda. Reenvie cada imagem que fica com todos os campos lidos: markUp e viewpoint que faltarem são apagados.

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 (§9). 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.

    Não use remove: true na alteração individual. Para remover uma disciplina, omita-a e envie a lista final com as demais. remove: true é suportado somente pela mutation em lote (lote).

    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.

    A situação também determina os valores efetivos: em participate e follow, o prazo fica nulo; doneAt fica nulo fora de validate, validated e done; nessas três situações, omitir doneAt registra a data atual.

  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

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 só 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.

⚠️ Uma alteração em lote pode sobrescrever edições concorrentes, inclusive em campos que não aparecem em newValues. Não use lote sobre registros que pessoas estejam editando sem antes comparar a versão lida com a versão atual (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 delesa operação inteira pode ser recusada, sem gravar nem os apontamentos válidos

É 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 (§9), 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

⚠️ ISSUE_NOT_FOUND não confirma que o identificador esteja errado. Trate-o como resposta final, sem tentar inferir a existência ou a visibilidade do apontamento.

🔴 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 — as duas restrições que surpreendem​

⚠️ O suporte de imagens em createIssues é limitado. 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.
  • Não use envio direto de arquivo (originalFile, markedUpFile, markedUpThumbFile) no lote.
  • Não conte com geração de miniatura nem definição de capa pelo lote.

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.

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.

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 (§6).

Evite sobrescritas concorrentes​

Assuma que o apontamento pode mudar entre a leitura e a escrita. Uma gravação concorrente pode sobrescrever a anterior sem produzir um conflito explícito. Use os marcadores de edição para impedir que a integração apague trabalho humano:

  • 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 index title original markedUp markedUpThumb markUp viewpoint }
}
}

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 é a lista completa das imagens: a gravada que não vier é removida, e images: [] remove todas; sem o campo, nada muda. Reenvie cada imagem que fica com todos os campos lidos: markUp e viewpoint que faltarem são apagados.

Input de imagem do comentário:

input IssueCommentImageInput {
id: Int
type: String
title: String!
cover: Boolean
deleteImage: Boolean # obsoleto
viewpoint: JSON
markUp: JSON
index: Int # obrigatório
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 (§8).

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
Validade da URL1 hora
Método aceitoPUT
Limite de taxaO volume total da identidade (user-envelope), como qualquer outra chamada (§6)

⚠️ 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 faz o armazenamento recusar a requisição. Envie o Content-Type devolvido pela mutation e nenhum outro cabeçalho além dos exigidos pelo cliente HTTP.

Teto de tamanho. O arquivo enviado pela URL assinada tem o teto do anexo: 10 MB para imagem PNG ou JPEG — o nome terminado em .png, .jpg ou .jpeg — e 5 MB para o resto. Acima dele, o arquivo não fica guardado, mesmo com o PUT respondendo 200, e a mutation que usar o endereço dele é recusada com UPLOAD_FILE_SIZE_EXCEEDED. Confira o tamanho antes de enviar.

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", index: 0, original: "<url final>", markedUp: "<url final>" }]

🔴 A miniatura só é gerada se markedUp estiver preenchido. Quem faz os três passos e envia só 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 limite de emissão é restrito: não pré-aloque para depois e não abra N chamadas paralelas para N arquivos.

Viewpoints​

O viewpoint é a vista 3D salva no apontamento, enviado em viewpoints: [IssueViewpointInput] no createIssue e no updateIssue. A forma, a tipagem e as regras de obrigatoriedade são as da BCF API 3.0 da buildingSMART:

O que difere do padrão:

buildingSMARTConstruflow
snapshot com snapshot_type e snapshot_data, em base64 (snapshot_POST.json)snapshot com original e markedUp, os endereços da imagem enviada pelos três passos; markedUpThumb e as anotações em markUp são opcionais
bitmaps aceitosrecusados
components aceitosrecusados
sem tetoaté 500 viewpoints por apontamento, 64 planos de corte e 500 linhas por viewpoint, 256 KB por viewpoint; números até 10¹² em módulo, proporção até 100 e endereço da captura até 2.048 caracteres
propriedade a mais ignorada em qualquer nível (Foundation API, 1.8)ignorada dentro das câmeras, dos cortes e das linhas; nos campos do próprio viewpoint e de snapshot, recusada

index ordena a lista, como no padrão, e volta na leitura; sem ele, vale a ordem enviada.


13. 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.

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": "..." }.

🔴 Não use visibility: "group". Esse valor não faz parte do contrato da importação e faz todos os tópicos falharem. Use creator, project_management ou public.

⚠️ commentVisibility não vem do arquivo BCF. Se o arquivo puder conter comentários, envie o parâmetro explicitamente; sem uma visibilidade suportada, comentários podem falhar mesmo quando o apontamento é importado. Não use group.

🔴 Não trate commentVisibility como controle isolado de confidencialidade. A visibilidade efetiva também depende das permissões da credencial e da visibilidade do apontamento, e pode ficar mais ou menos restritiva do que o valor pedido. Use um usuário dedicado com acesso mínimo e, depois da importação, consulte os comentários e confira a visibilidade gravada.

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".

🔴 Um PUT com sucesso não confirma a importação. Fora da janela de 200 segundos, o upload pode ser aceito sem que o arquivo seja processado, e o usuário dono da credencial recebe o e-mail "⚠️ Importação BCF não concluída!" com o motivo "O arquivo não chegou a tempo para o processamento.". Se perder a janela, abra um novo processo e envie o arquivo pela nova URL.

Passo 3 — o resultado​

O resultado chega por e-mail ao usuário dono da credencial, num de dois e-mails: o de importação concluída — quantos apontamentos foram criados, quantos foram atualizados, e a lista de tópicos que falharam com o motivo de cada um — ou o de importação não concluída, com o motivo e o que fazer.

⚠️ 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 (§8).

⚠️ Receber a URL confirma apenas a abertura do canal de upload. Antes de começar, valide que o identificador do projeto é numérico, que o projeto está ativo e que a credencial tem permissão de visualizar e participar. A aceitação do arquivo e de cada tópico só é confirmada pelo e-mail de resultado; falhas podem afetar toda a importação ou apenas tópicos específicos. Confira também o efeito pela API antes de considerar a operação concluída.

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 considerado é o Guid do arquivo de visualização (.bcfv), não o do tópico. Para ter comportamento previsível, emita esse atributo e mantenha-o estável entre rodadas. Não dependa do comportamento de viewpoints sem identificador.

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 A prioridade "High" não existe no Construflow (use Alta, Média ou Baixa). Ajuste o tópico e importe de novo. 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 nada — ARQ 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 O tópico não tem disciplina responsável. Atribua a ele uma disciplina do projeto e importe de novo. 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 (§12), 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 (§7) — 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 O local "ND" informado no arquivo não existe neste projeto. Use um local do projeto e importe de novo.

⚠️ 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 — no e-mail, A categoria do tópico está desativada neste projeto. Ative-a no projeto, ou use outra categoria no tópico, e importe de novo. / A fase do tópico está desativada neste projeto. Ative-a no projeto, ou use outra fase no tópico, e importe de novo.. 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 (§7).

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 (§7) — é o que evita descobrir cinquenta falhas depois do fato.

Armadilhas da importação​

Além das acima, três falhas que exigem tratamento explícito:

  • 🔴 Subir o arquivo depois dos 200 segundos pode aceitar o PUT sem importar nada. O e-mail que chega é o de importação não concluída, com o motivo "O arquivo não chegou a tempo para o processamento.". Não use o sucesso do upload como confirmação. 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 é o de importação não concluída, com o motivo "O arquivo não é um BCF válido, está corrompido ou está numa versão não aceita (aceitamos 2.1 e 3.0).", e nenhum apontamento é criado. Só 2.1 e 3.0 são aceitos.
  • ⚠️ 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 — assim como o comentário cujo texto é só <no comment>, em maiúsculas ou minúsculas.

14. 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 credencial enxerga exatamente o que esse usuário enxerga. §3
  • Conectar pelo OAuth. §3
  • Executar a integração somente no servidor; nunca expor o access token nem o refresh token em navegador, aplicativo móvel ou outro cliente distribuído. §3
  • Em script conectado por código, renovar o refresh token num processo só e gravar o novo antes de usar o access token; com invalid_grant, reler o refresh token gravado antes de pedir um código novo. §3
  • Enviar um User-Agent identificável em toda requisição. §6
  • Desligar o agrupamento automático de operações do seu cliente GraphQL (BatchHttpLink e equivalentes). §6
  • 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. §5
  • Implementar a política de retry por status: 400 nunca; 429 pelo Retry-After; 5xx com backoff e jitter. §5
  • Tratar 429 pelo status e pelos cabeçalhos, nunca pela forma do corpo — recusas do limite total vêm sem a lista errors. §6
  • Colocar um limitador global de ~1 requisição por segundo e no máximo 2 simultâneas (1 processo em sequência, no máximo 2). §6

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. §7
  • Carregar o cadastro de cada projeto numa chamada só e guardá-lo do seu lado. §7
  • 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. §8
  • Gravar por upsert idempotente — bordas de janela inclusivas repetem o item da borda. §8
  • Saber que a carga não traz comentário de apontamento excluído — esse dado não existe pela API. §8

Em cada rodada​

  • Reenumerar os projetos e comparar o conjunto de identificadores com o da rodada anterior — trate um projeto que sumiu como mudança de acesso e reconcilie o estado local. §7 · §10
  • Usar como janela [início da rodada anterior, início desta rodada] — nunca o fim da anterior, nunca "de agora para trás". §8
  • Enviar as datas com fuso explícito (Z ou -03:00). §8
  • Rodar as duas consultas, apontamentos e comentários. Comentário novo não move a data do apontamento. §8
  • Enviar filter.includeDeleted: true no passo 1 — sem ele a exclusão de apontamento é invisível, sem erro nenhum. §11
  • 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. §8
  • 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. §6 · §11
  • Iterar até hasNextPage ser false — a última chamada vazia é esperada. §11
  • 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. §10 · §8
  • 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. §9

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. §7 · §10
  • Agendar a recarga total por projeto (mensal, por exemplo) e reconciliar por conjunto de identificadores. §10
  • Investigar todo projeto ativo cuja rodada voltou vazia — pode ser perda de acesso, que não produz erro. §10

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. §12 · §7
  • Na criação, sempre enviar locals e disciplines não-vazios — omitir cria o apontamento e depois estoura, deixando um registro órfão. §12
  • Enviar só os campos que mudaram — campo omitido é preservado. §12
  • Nunca mandar [] querendo dizer "não mexer": apaga em etiquetas e referências, e é recusado em locais e disciplinas. §12
  • Enviar resolutionPhase junto com status — o gatilho é enviar, não mudar — e manter status e disciplines coerentes entre si. §12
  • 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. §12
  • Lembrar que o prazo do apontamento é derivado das disciplinas — enviar disciplinas sem prazo o apaga. §12
  • No lote, percorrer rejectedIssues: a mutation devolve 200 sem errors mesmo com apontamentos recusados. §12
  • Ao editar comentário, reenviar mentionedUsers sempre — omitir apaga todas as menções. §12
  • Comparar editedAt antes de sobrescrever e tratar toda escrita como concorrente. §12
  • 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. §12
  • Para criar apontamento com imagem, usar createIssue individual: a criação em lote não processa imagens. §12
  • Manter o de-para de situação, prioridade e visibilidade do seu lado — a API não traduz. §9

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. §13
  • Emitir TopicType com o número da categoria antes do hífen (03 - …), não com sigla. §13
  • Emitir os locais como caminho completo de abreviações (^TORRE/PAV2), lido do abbreviationTree do cadastro. §13
  • Garantir que exista um local de abreviação ND no projeto, se algum tópico puder vir sem local. §13
  • Não contar com AssignedTo para e-mails: entrada com @ é descartada em silêncio. §13
  • Enviar commentVisibility sempre que o arquivo puder conter comentários — e conferir a visibilidade gravada, que pode ter sido rebaixada. §13
  • Subir o arquivo imediatamente após abrir o processo — a janela é de 200 segundos, e o PUT fora dela pode ter sucesso sem importar nada. §13
  • Apontar a credencial para uma caixa de e-mail monitorada: o resultado só chega por lá. §13
  • Conferir a situação dos tópicos depois de importar — TopicStatus desconhecido vira ativo em silêncio. §13
  • 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. §13