OpenAPI reference: Sites & organizations (canverly-tenancy)
This page is generated at build time from
api/openapi.yamlin thecanverly-tenancyrepository. Do not edit it by hand: the source is the specification.
- Specification version:
0.1.0 - Total operations: 17
- Declared servers:
https://api.canverly.com/api - Published specification (YAML): /specs/tenancy-openapi.yaml
Operations
Section titled “Operations”POST /v1/sites
Section titled “POST /v1/sites”Create a site under an org
Authentication: none declared
Request body
Section titled “Request body”Content types: application/json (object)
Responses
Section titled “Responses”| Status | Description |
|---|---|
201 |
site created |
400 |
corpo inválido ou org_id que não é ULID nem UUID |
403 |
FORBIDDEN — o org_id do CORPO não está entre as memberships validadas na borda (X-Org-Memberships). É a defesa de mass-assignment do caminho de ESCRITA: sem ela, bastaria trocar o org_id do corpo para criar site sob a empresa de outro cliente. Chamador serviço-a-serviço (sem o cabeçalho) atravessa. |
409 |
Conflito, com code distinguindo: DOMAIN_TAKEN (domínio já pertence a outro site), SLUG_TAKEN (slug em uso) e SITE_LIMIT_REACHED (o plano da empresa não comporta mais um site licenciado — recusa deliberada, melhor não criar do que entregar site que já nasce fora do ar). |
500 |
falha interna (detalhe redigido, §22.8) |
GET /v1/sites/batch
Section titled “GET /v1/sites/batch”Resolve VÁRIOS sites por id numa requisição
Substitui o fan-out de N chamadas a /v1/sites/{id} que o painel fazia para nomear os sites do usuário — 47 requisições simultâneas para quem enxerga a frota inteira, cada uma pedindo uma conexão do pool. Mesma visibilidade de cliente do GET por id: site apagado ou arquivado NÃO volta. Autorização por INTERSEÇÃO com X-Site-Memberships — id não concedido é OMITIDO, nunca recusado com 403 (recusar confirmaria a existência). Sessão de consumer recebe lista vazia: a rota é staff-only.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
ids |
query | yes | CSV de ULIDs. Máximo de 200 por chamada. |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
ok |
400 |
ids ausente/vazio, com id inválido, ou acima do teto de 200 |
GET /v1/sites/{id}
Section titled “GET /v1/sites/{id}”Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
site |
404 |
not found |
PATCH /v1/sites/{id}
Section titled “PATCH /v1/sites/{id}”Atualização parcial do site (tema, settings, domínios, idiomas)
Só os campos presentes são aplicados. settings SUBSTITUI o documento inteiro (não é merge).
IMAGEM EXTERNA → CDN — depois de validado e saneado, toda URL externa de imagem do settings (logo_url, favicon_png_url, favicon_ico_url, seo.default_og_image, logo.{light,dark}.sources[].url, favicon_kit, ads.units[].creative.image_url, <img src|srcset> do about_html) é enviada ao canverly-imagem-ingest (espera até ~8s). Pronta → a resposta e o banco já trazem a URL da CDN (e logo_media_id/favicon_*_media_id recebem o asset). Não pronta, reprovação transitória ou ingest fora → a URL externa é mantida e o PATCH responde 200 do mesmo jeito; o worker troca depois. Imagem NOVA (ausente do settings gravado) que o ingest reprova DE VEZ (definitive: true — 404/410, não é imagem, grande demais, esquema proibido, host não público, SVG hostil) → 422 IMAGEM_EXTERNA_INVALIDA e NADA é gravado. URL que já estava gravada nunca recusa (o retroativo cuida dela).
SANEAMENTO NA ESCRITA — settings carrega markup de terceiro, e esta é a única porta por onde ele entra. Regra, e o porquê da assimetria:
(a) logo_svg e favicon_svg são SANEADOS e aceitos: remove-se <script>, atributos on*=, URLs javascript:, <foreignObject>, <use>/<image> com href externo, <link>, elementos de animação, @import/url(http…) em <style> e o prólogo XML (DOCTYPE/<!ENTITY>). O que fica guardado é o markup JÁ limpo. Existe versão limpa de um documento SVG, e recusar por causa de um DOCTYPE que o Illustrator escreve sozinho faria um envio legítimo parecer quebrado. SVG legítimo atravessa sem alteração (a operação é idempotente). Teto de 32 KiB.
(b) As URLs (logo_url, favicon_png_url, favicon_ico_url, seo.default_og_image) são RECUSADAS quando não são http(s) nem caminho absoluto do próprio site — javascript:, data:, //host e qualquer valor com espaço/aspas/< > dão 400. URL é valor atômico: não existe versão limpa de javascript:alert(1), e apagar o campo em silêncio faria o dono ver “salvo” com o ícone sumido.
(c) Um campo de SVG que, DEPOIS do saneamento, já não é um SVG (ou passa do teto) também é 400 — guardar “” ali seria um sucesso mentiroso.
(d) settings.logo — a logo com variante CLARA e ESCURA e formatos concorrentes. Contrato canônico: canverly_archive/logo-contract-golden.json, replicado byte a byte no tenancy, no web-admin e no web-public; o SHA-256 é cravado nas três suítes, então renomear um campo em um repositório deixa os outros dois vermelhos no CI.
logo.version 1
logo.alt texto alternativo (<=200); “” = logo decorativa
logo.width inteiro 8..2048, OBRIGATÓRIO quando há logo
logo.height idem — reservam a caixa no render (CLS)
logo.light variante padrão, OBRIGATÓRIA quando logo existe
logo.dark variante do modo escuro, OPCIONAL (cai para a clara)
Cada variante é OU svg_markup (markup inline, saneado pelo MESMO saneador de logo_svg — inclusive o SVG que chega por upload de arquivo), OU sources: [{type, url}] — nunca as duas, e as duas variantes usam a MESMA forma. type vem da allowlist image/svg+xml|image/avif|image/webp|image/png|image/jpeg, no máximo um por formato e no máximo 5 por variante; a ordem canônica é do mais capaz para o menos, porque o navegador fica com o PRIMEIRO <source> que sabe decodificar. url segue a regra APERTADA do favicon (https://host/caminho ou caminho do próprio site): a logo termina num <img src> de página https, e http:// seria bloqueado como conteúdo misto — “salvo” com a logo ausente. Os campos LEGADOS logo_svg/logo_url continuam válidos e são a reserva de quem ainda não migrou.
A variante escura segue a MESMA decisão de settings.customization.theme_base que a paleta do site — não uma paralela. theme_base aceita light|auto|dark|sepia|high_contrast; auto estava faltando na allowlist e era recusado com 400 apesar de o painel oferecê-lo e o site resolvê-lo (corrigido em 2026-08-10).
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
Request body
Section titled “Request body”Content types: application/json (object)
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
site atualizado (com o markup já saneado) |
400 |
validação. code distingue o motivo: SETTINGS_INVALID_SVG (o campo de SVG não sobreviveu ao saneamento ou passa de 32 KiB), SETTINGS_INVALID_ASSET_URL (URL de imagem fora de http(s)/caminho absoluto), além dos códigos de customization/ads/applets. Anúncio próprio (settings.ads.units[].type: "internal"): ADS_UNIT_CREATIVE_INVALID (falta campo obrigatório do criativo — target_url, width, height, image_alt ou headline), ADS_UNIT_CREATIVE_URL_INVALID (image_url/target_url fora da regra de URL de ícone: https com caminho, ou caminho do próprio site), ADS_UNIT_UNKNOWN_KIND (creative.kind fora de image|text) e ADS_UNIT_MARKUP_FORBIDDEN (a chave html NÃO existe em unidade NENHUMA: o criativo é estrutural e o markup arbitrário tem lugar próprio em settings.code_injection). ADS_UNIT_HTML_RETIRED: o tipo de unidade "html" foi REMOVIDO em 2026-08-10 — aceitava markup arbitrário do tenant renderizado na origem do site. Migre para "internal"; para código arbitrário use settings.code_injection. Logo (settings.logo): LOGO_INVALID_SHAPE, LOGO_INVALID_VERSION, LOGO_INVALID_ALT, LOGO_INVALID_SIZE (width/height ausente, não inteiro ou fora de 8..2048), LOGO_INVALID_VARIANT, LOGO_INVALID_SOURCE ({type,url} malformado ou url vazia), LOGO_UNSUPPORTED_TYPE (formato fora da allowlist), LOGO_DUPLICATE_TYPE (dois sources do mesmo formato — o segundo seria inalcançável pelo navegador), LOGO_TOO_MANY_SOURCES, LOGO_MIXED_FORM (svg_markup e sources juntos, ou variantes com formas diferentes) e LOGO_EMPTY_LIGHT (dark sem light). A URL de sources reaproveita SETTINGS_INVALID_ASSET_URL, e o svg_markup reaproveita SETTINGS_INVALID_SVG — com o caminho completo do campo na mensagem (settings.logo.dark.svg_markup). Modais (settings.modals, contrato §2): MODALS_INVALID_SHAPE (não é um array de objetos), MODALS_TOO_MANY (mais de 10 modais), MODALS_INVALID (campo obrigatório ausente, tipo errado ou fora do limite — id 1..64 e único, title 1..120 obrigatório porque é o aria-labelledby do diálogo, content obrigatório), MODALS_UNKNOWN_FIELD (chave fora do contrato, em QUALQUER nível — modal, content, rules, trigger, pages, frequency, audience. Diferente de settings.ads, que preserva chave desconhecida por causa de dado legado: settings.modals é subárvore nova e recusa, para fechar a classe “o painel grava formId e o site lê form_id”. Campo novo exige o tenancy no ar ANTES do painel), MODALS_MARKUP_FORBIDDEN (a chave html NÃO existe em nível nenhum, e content.text é TEXTO — não aceita marcação; markup arbitrário tem lugar próprio em settings.code_injection), MODALS_CONTENT_UNKNOWN_KIND (content.kind fora de form|ad|text), MODALS_CONTENT_REF_UNKNOWN (content.unit_id não referencia nenhuma unidade definida em settings.ads.units[].id), MODALS_CONTENT_INVALID_ULID (content.form_id não é ULID de 26 caracteres Crockford base32 — UUID não serve), MODALS_TRIGGER_UNKNOWN_KIND (rules.trigger.kind fora de delay|scroll|exit_intent), MODALS_TRIGGER_INVALID (seconds fora de 1..300 — o mínimo de 1s é o piso “nunca antes do LCP” — ou percent fora de 1..100), MODALS_PAGES_INVALID (padrão de rules.pages.include/exclude inválido: até 20 por lista, cada um 1..200 chars, começando com /, sem .., \, ://, caractere de controle ou mais de 5 *), MODALS_FREQUENCY_INVALID (max_shows fora de 1..50, days fora de 1..365, ou per diferente de days — days é o único valor legal hoje; aceitar session é mudança de contrato), MODALS_AUDIENCE_INVALID (devices vazio ou fora de desktop|mobile, new_visitors_only não-booleano). |
404 |
site inexistente OU não pertencente ao chamador (indistinguíveis) |
409 |
domínio já pertence a outro site |
422 |
IMAGEM_EXTERNA_INVALIDA — uma imagem NOVA do settings foi reprovada de vez pelo canverly-imagem-ingest; nada foi gravado. detail cita a primeira (“e mais N” quando há outras); invalid_images lista até 20. |
500 |
falha interna (detalhe redigido, §22.8) |
DELETE /v1/sites/{id}
Section titled “DELETE /v1/sites/{id}”Soft-delete do site (status=‘deleted’; reversível, dado preservado)
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
204 |
apagado |
400 |
id que não é ULID nem UUID. Chega ao handler porque o byIDSiteGuard do grupo /v1/sites deixa passar o segmento que não parseia (é ele que permite /by-host e /batch conviverem com /{id}); a validação de formato é do handler. |
404 |
not found |
500 |
falha interna (detalhe redigido, §22.8) |
GET /v1/sites/{id}/applets
Section titled “GET /v1/sites/{id}/applets”Catálogo de aplicativos com o estado de instalação DESTE site
Devolve o catálogo INTEIRO (é uma vitrine), com enabled e installed_at por app. Atrás do byIDSiteGuard: exige membership no site, como o resto de /v1/sites/{id}.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
catálogo + estado |
404 |
site não encontrado |
PUT /v1/sites/{id}/applets/{slug}
Section titled “PUT /v1/sites/{id}/applets/{slug}”Instala ou desinstala um aplicativo neste site
Grava settings.applets.<slug>.enabled. Para app com post_type_template, materializa (ou remove) o tipo de conteúdo no authoring ANTES de gravar a flag — se o authoring estiver fora, a resposta é 502 e NADA é gravado. Depois de gravar, o full-page cache do site é invalidado (best-effort): instalar/desinstalar muda quais rotas o site tem, e o cache não descobre isso sozinho.
DESINSTALAR NUNCA APAGA DADO. O app “forms” é o caso explícito: os formulários e os leads continuam em forms.forms/forms.leads; o que some é a ÁREA (o painel esconde, o canverly-forms recusa — inclusive a rota anônima de submissão, com 404 opaco — e o web-public pula o modal de formulário). Reinstalar devolve tudo como estava.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
slug |
path | yes | — |
Request body
Section titled “Request body”Content types: application/json (object)
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
estado do app depois da operação |
400 |
corpo inválido, ou SETTINGS_INVALID_APPLETS |
404 |
slug fora do catálogo (APPLET_NOT_FOUND) ou site inexistente |
502 |
falha ao materializar o tipo de conteúdo no authoring; nada foi gravado |
POST /v1/sites/{id}/legal/generate
Section titled “POST /v1/sites/{id}/legal/generate”Gera as páginas legais do site (app “Páginas legais”)
Monta os documentos do CASO do site (settings.legal.perfil e setor) a partir dos fatos da configuração e grava cada um como PÁGINA com fields = {secao: "legal", documento_legal, ordem} — URL canônica /legal/ (índice) e /legal/<slug>. Página nova nasce RASCUNHO. Página existente no slug: rascunho do gerador é regravado (atualizada); publicada ou agendada NUNCA é sobrescrita (existente_publicada) e rascunho escrito à mão é preservado (existente_preservada) — nos dois casos só recebe os fields que a movem para /legal/ (adotada: true), e as pendências do texto novo voltam para o dono decidir. O índice lista só as páginas que existem depois do lote. O lote nunca aborta por causa de um documento.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
X-User-Id |
header | yes | — |
Request body
Section titled “Request body”Content types: application/json (object)
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
um resultado por documento, na ordem do catálogo |
400 |
corpo inválido ou slug desconhecido (LEGAL_UNKNOWN_DOC) |
401 |
sem X-User-Id |
404 |
site não encontrado |
409 |
app Páginas legais desligado (APPLET_DISABLED) |
GET /v1/sites/{id}/legal/catalogo
Section titled “GET /v1/sites/{id}/legal/catalogo”Documentos que a geração produziria para o site
A seleção por perfil/setor SALVOS; perfil e setor na query os sobrepõem (o formulário mostra a lista antes de salvar). Não exige o app ligado — é leitura derivada do settings.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
perfil |
query | no | — |
setor |
query | no | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
array na ordem do índice (o índice legal primeiro) |
400 |
perfil ou setor desconhecido (LEGAL_INVALID_CASE) |
404 |
site não encontrado |
GET /v1/platform/sites
Section titled “GET /v1/platform/sites”Lista TODOS os sites (cross-tenant) para o operador de plataforma
Inclui os arquivados, com motivo (status_reason) e desde quando (status_changed_at). Exige capability de operador. Aceita busca (q, ILIKE parcial em slug e primary_domain) e os filtros exatos org_id e status; sem parâmetro nenhum a resposta é a listagem completa de sempre. Os filtros entram na MESMA cláusula do keyset, então paginar com busca junto não repete nem pula linha.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
q |
query | no | Termo de busca (ILIKE parcial, sem diferenciar caixa) em slug e primary_domain. Curingas de LIKE (%, _) são tratados como literais; o termo é saneado e truncado em 128 caracteres. |
org_id |
query | no | filtro exato pela empresa dona (ULID base32 ou UUID) |
status |
query | no | filtro exato de estado; rótulo fora do enum devolve 400 (nunca lista tudo) |
cursor |
query | no | Id da última linha da página anterior (aceita ULID base32 OU UUID — o next_cursor devolvido vem em UUID). Valor que não é id devolve 400; nunca 500. |
limit |
query | no | — |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
página de sites |
400 |
status fora do enum |
403 |
sem capability de operador |
GET /v1/platform/sites/{site_id}
Section titled “GET /v1/platform/sites/{site_id}”Detalhe de um site (cross-tenant, painel do operador)
Os mesmos campos da listagem mais o ciclo de vida (status_reason, status_changed_at, previous_status), o default_language, o updated_at e o bloco domains (primário, aliases e host canônico). Lê o site em QUALQUER estado — arquivado e deletado inclusive, que é o que o cliente não enxerga. NÃO traz contagem de posts publicados: esse dado é do canverly-authoring e buscá-lo aqui criaria dependência de runtime entre os dois serviços (§10) só para preencher um número de tela.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
site_id |
path | yes | ULID base32 ou UUID |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
detalhe do site |
400 |
site_id não é ULID nem UUID |
403 |
sem capability de operador |
404 |
site inexistente |
DELETE /v1/platform/sites/{site_id}
Section titled “DELETE /v1/platform/sites/{site_id}”Purgar um site DE VEZ (hard-delete, painel do operador)
Apaga o cadastro do site em definitivo e LIBERA o domínio e o slug, que o soft-delete mantinha reféns. O fluxo previsto é em duas mãos: o cliente faz o soft-delete (o site some do painel dele) e o operador de plataforma confirma a purga — por isso operator_admin, e não operator_read.
Invalida o cache host-keyed na mesma chamada, para o site parar de servir na hora em vez de sobreviver até o próximo ciclo do resolvedor. A ação é registrada na trilha de auditoria com QUEM a executou: é cross-tenant e irreversível.
NÃO apaga o ACERVO do site nos demais serviços (posts, mídia, termos): cada serviço é dono do próprio schema, e a limpeza de conteúdo é um fluxo à parte.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
site_id |
path | yes | ULID base32 ou UUID |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
204 |
site purgado; domínio e slug liberados |
400 |
site_id não é ULID nem UUID |
403 |
sem capability de operador (a leitura não basta aqui) |
404 |
site inexistente |
500 |
falha ao apagar |
POST /v1/platform/sites/{id}/deactivate
Section titled “POST /v1/platform/sites/{id}/deactivate”Despublica o site (painel do operador)
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
estado resultante |
403 |
operator_read não escreve |
POST /v1/platform/sites/{id}/archive
Section titled “POST /v1/platform/sites/{id}/archive”Arquiva o site (painel do operador)
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
estado resultante |
403 |
operator_read não escreve |
POST /v1/platform/sites/{id}/restore
Section titled “POST /v1/platform/sites/{id}/restore”Restaura o site (painel do operador)
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
X-Platform-Operator |
header | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
estado resultante |
403 |
operator_read não escreve |
GET /v1/sites/{id}/health
Section titled “GET /v1/sites/{id}/health”Pendências de um site (detalhe atrás do sininho)
Lê SÓ o cache. Autorização pelo mesmo byIDSiteGuard das demais rotas por id: quem não é membro do site (nem tem a permissão de org equivalente) recebe 404 OPACO, idêntico ao de um site inexistente — um 403 confirmaria a existência do site.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
estado de saúde do site (cached=false quando nunca verificado) |
400 |
id não é ULID nem UUID |
404 |
site inexistente OU não pertencente ao chamador (indistinguíveis) |
503 |
cache de saúde não wired |
GET /v1/sites/{id}/domains/check-dns
Section titled “GET /v1/sites/{id}/domains/check-dns”O host aponta para a plataforma? (A, AAAA ou CNAME)
Compara as TRÊS formas de apontamento que a plataforma aceita, porque as três são configurações válidas que a própria tela ENSINA: registro A no apex, AAAA no apex e CNAME de subdomínio para <slug>.canverly.com.
ANTES comparava SÓ o registro A — todo subdomínio corretamente apontado por CNAME e todo apex só-IPv6 eram reportados como quebrados, e o veredito CONTRADIZIA o da varredura horária de saúde (que já comparava os três) sobre o mesmo domínio. A comparação agora é a MESMA função (application.LookupDNS), não uma cópia.
Autorização pelo byIDSiteGuard do grupo /v1/sites: quem não é membro do site recebe 404 opaco antes deste handler rodar.
Authentication: none declared
Parameters
Section titled “Parameters”| Name | In | Required | Description |
|---|---|---|---|
id |
path | yes | — |
host |
query | yes | — |
Responses
Section titled “Responses”| Status | Description |
|---|---|
200 |
veredito + registros publicados |
400 |
id inválido ou host ausente |
404 |
site inexistente OU não pertencente ao chamador (indistinguíveis) |
500 |
endereço da frota não configurado (falha alto em vez de rotular tudo errado) |