A API da ferramenta Páginas do Asender: constrói a landing page, publica, e mede o que acontece nela.
Vai criar ou editar o CONTEÚDO de páginas (você ou um agente de IA)? Leia Páginas: o JSON do documento v2: modelo mental, todos os campos de todos os itens, erros de validação, mídia, fluxos seguros (rascunho/versão/publicação), receitas completas e um prompt pronto. É uma ferramenta servida por dois processos — a
autoria (o construtor: páginas, formulários, mídia, domínios, links curtos, marca, geração por IA, testes A/B) e o runtime (o que serve a página publicada ao visitante e a mede: captura de lead, analytics, pixels, session replay). Para quem integra, é uma API só, num endereço só.
Base (tudo o que você integra):https://api.pages.asender.net — autoria, leitura de métricas e o envio de lead server-to-server (o proxy roteia por caminho para o runtime).
Runtime público (página publicada, snippet, pixel):https://sites.asender.net — também serve o envio de lead por chave, igual ao api.pages (o antigo https://sites.asender.com.br continua servindo igual, sem redirecionar — formulário e integração já colados com ele seguem funcionando)
Formato: JSON (application/json; charset=utf-8)
Por que dois endereços, e onde passa a fronteira. A fronteira interna autoria/runtime não é sua — você não precisa saber por onde ela passa. A regra prática é simples: tudo o que você administra (páginas, formulários, pixels, relatórios) fala com api.pages.asender.net; tudo o que o visitante da página publicada dispara (o formulário embutido, o pixel, o link curto) acontece em sites.asender.net, e você quase nunca chama isso à mão — é a página servida que chama. sites.asender.netnão é uma API: é onde a página publicada é servida. Se o runtime cair, o que quebra é a analytics de Páginas — não o CRM, não o e-mail, não a plataforma (ADR-0037).
Autenticação
Páginas tem três públicos, e cada um usa uma credencial diferente. A spec diz qual vale em cada rota.
Sessão (OIDC) — a autoria e a leitura de métricas
O construtor e as telas de analytics/pixels/replay são operados por um usuário logado. A credencial é a sessão OIDC (OAuth 2.1 + PKCE, emitida por https://auth.asender.net). O token vai em Authorization: Bearerou no cookie asender_session (o caminho dos frontends da casa):
O X-Asender-Tenant é uma preferência — diz em qual das suas contas você está operando. Ele não autoriza nada: o acesso é decidido no servidor pelo PEP do produto pages, e o tenant efetivo é sempre resolvido do token. Sessão ausente ou inválida responde 401; a sessão de um usuário sem o produto pages na conta responde 403.
API key — integração server-to-server (formulários, leads e páginas)
Um sistema seu — ou um agente de IA — pode administrar formulários, enviar leads e criar e manter páginas sem sessão de usuário, com uma API key da conta (sk_live_…/sk_test_…). A chave vai em Authorization: Bearer e é validada a cada chamada — chave revogada para de valer na hora (sem cache). A conta é resolvida da chave; um tenant_id no corpo é ignorado (piso 3). Em Páginas a chave vale só nas rotas abaixo, e cada uma exige um escopo (deny-default: rota fora da tabela, ou chave sem o escopo, responde 403):
Escopo
Rotas
forms:read
GET /v1/formularios (aceita ?external_id=), GET /v1/formularios/{id}, GET /v1/formularios/externo/{external_id}
pages:read
GET /v1/paginas, GET /v1/paginas/{id}, GET /v1/paginas/{id}/rascunho, GET /v1/paginas/{id}/seo, GET /v1/midias, GET /v1/dominios (lista os domínios da conta; o padrão vem primeiro, com padrao: true)
pages:write
criar e editar sem publicar: POST /v1/paginas, POST /v1/paginas/importar, POST /v1/paginas/{id}/versoes, PUT/DELETE /v1/paginas/{id}/rascunho, PUT /v1/paginas/{id}/seo, POST /v1/paginas/{id}/midias/copiar, PUT /v1/paginas/{id}/dominio (move a página de domínio e/ou caminho), POST /v1/paginas/lote (duplicar, mudar_dominio), POST /v1/midias, POST /v1/midias/de-url
pages:publish
publicar e despublicar — o ato visível ao público: POST /v1/paginas/{id}/publicar (com versao obrigatória) e POST /v1/paginas/lote (publicar, despublicar)
forms:write
POST /v1/formularios, PUT /v1/formularios/{id}, PUT /v1/formularios/externo/{external_id}, DELETE /v1/formularios/{id}, e o envio de lead: POST /v1/leads, POST /v1/forms/{formID}/submissions, POST /v1/forms/externo/{externalID}/submissions
Os escopos não se implicam (pages:write não lê nem publica). Todo o resto (editar slug/título, A/B, roteamento, destinos, demais rotas de domínios, apagar mídia, SEO do site, analytics…) é só sessão: com chave responde 401/403. Contra sobrescrever trabalho de pessoas, o PUT …/rascunho por chave exige ?base= (428 sem ele; 409 com os dois lados preservados) e a escrita é limitada por chave (60/min; publicar 20/min). O guia completo para agentes, com o contrato do documento, está em Documento de página v2. Passo a passo em Integrar um formulário externo.
Como obter uma chave. As API keys são emitidas pela Plataforma (POST https://api.asender.net/api/tenants/{id}/api-keys, ver a Platform API) ou pela tela de API keys do painel, informando os scopes (forms:read, forms:write, pages:read, pages:write, pages:publish). Use sk_test_… fora de produção.
Sem credencial — o visitante da página publicada
O snippet de formulário embutido em site de terceiro (POST /f/{formID}), a coleta de pixel (/pixel.js, POST /collect), a gravação de session replay (POST /replay) e o link curto (GET /r/{slug}) são anônimos: quem chama é o navegador de um visitante, não um integrador. Quando há alguma credencial, ela é a do pixel — um token assinado que a página servida já carrega, enviado no corpo — nunca uma chave sua. Esses endpoints defendem-se por origem (a allowlist do formulário ou do pixel) e por rate-limit, não por sessão.
Erros
O envelope de erro difere por processo, e a spec diz a verdade sobre cada um.
Autoria (api.pages.asender.net, control plane) responde com a chave erro:
{ "erro": "já existe uma página no endereço \"promo\"" }
Runtime (captura, envio de lead por chave, analytics/pixels/replay) responde no formato RFC 7807-compatível da casa, com error.message:
{ "error": { "message": "credencial ausente ou inválida" } }
Os códigos e quando cada um aparece:
HTTP
quando
400
corpo inválido, parâmetro de query malformado, submissão recusada pela validação do formulário
401
sessão/credencial ausente ou inválida (chave revogada, conta inativa)
403
produto pages ausente na conta (autoria); chave sem o escopo da rota (a chave não tem o escopo forms:write); origem não autorizada (snippet/coleta)
404
recurso inexistente ou fora do escopo da credencial (não revela existência em outra conta)
409
conflito de unicidade (slug/nome/external_id já em uso, experimento já rodando)
413
upload de mídia grande demais
422
regra de negócio (título ausente, domínio não verificado, peso fora de 0–100); Idempotency-Key já usada com outro corpo
429
excesso nas rotas públicas de coleta/replay (rate-limit por credencial × IP) e no envio de lead por chave (300/min por conta) — traz Retry-After
502
a captura ou a geração por IA não pôde completar agora
503
dependência indisponível ou recurso desligado neste ambiente (IA, importação por URL, validação da chave) — tente de novo com backoff
Paginação e janelas
Páginas não usa cursor opaco (diferente da Mail API). As coleções de autoria (páginas, formulários, redirects, pixels) devolvem a lista sob uma chave nomeada ({"paginas":[…]}, {"formularios":[…]}, …); onde há muito volume, um ?limite=N recorta. Os relatórios de analytics trabalham por janela de tempo: ?de=…&ate=… em RFC3339; ausente usa os últimos 7 dias, e o teto é de 92 dias.
Autoria — o ciclo de vida de uma página
A base de tudo nesta seção é https://api.pages.asender.net, com sessão.
Criar, salvar versão, publicar
Uma página nasce com um título e (opcionalmente) um slug e um dominio próprio. Salvar o trabalho do editor cria uma versão; publicar promove uma versão ao ar.
Domínio próprio exige posse provada. Criar/editar uma página com um dominio que não está verificado na conta responde 422. Cadastre e verifique o host em /v1/dominios primeiro (abaixo).
Bloco privilegiado exige nível. Um template com bloco de HTML cru é barrado pelo PEP com 403 para quem não tem o nível — a página segue editável sem ele.
Endereço é único.slug+dominio repetido responde 409.
Domínios próprios
Adicionar um domínio devolve o registro TXT de posse a publicar no DNS; depois você pede a verificação.
curl -X POST 'https://api.pages.asender.net/v1/dominios' \
-H 'Authorization: Bearer <token-de-sessão>' \
-H 'X-Asender-Tenant: <slug-da-conta>' \
-H 'Content-Type: application/json' \
-d '{ "host": "lp.empresa.com.br" }'
# → 201 { "id":"…", "host":"lp.empresa.com.br", "registro_txt":"asender-verify=…", "verificado":false }
curl -X POST 'https://api.pages.asender.net/v1/dominios/<id>/verificar' \
-H 'Authorization: Bearer <token-de-sessão>' \
-H 'X-Asender-Tenant: <slug-da-conta>'
# → 200 { …, "verificado": true } (422 se o TXT ainda não propagou; 503 se o DNS não respondeu)
Domínio padrão e mover uma página de endereço
Toda conta ganha, automaticamente, um domínio padrão em apages.xyz. GET /v1/dominios (sessão ou chave pages:read) lista os domínios com o padrão primeiro e "padrao": true nele.
PUT /v1/paginas/{id}/dominio (sessão ou chave pages:write) move a página:
dominio_id: "" = host da plataforma. caminho: "" mantém o slug atual; o caminho é um slug de um segmento (^[a-z0-9]+(-[a-z0-9]+)*$).
O endereço antigo passa a responder 301 para o novo (308 se não for GET/HEAD), com a query string preservada, desde que a página já tenha sido publicada alguma vez (redirecionamento: null caso contrário). Repetir o pedido é idempotente (alterado: false).
Status
Quando
400
corpo inválido
401 / 403
sem credencial / chave sem pages:write
404
página ou domínio inexistente ou de outra conta (não revela existência)
409
motivo: caminho_em_uso (endereço de outra página) ou pagina_inicial (a página é a home do domínio de origem)
422
motivo: dominio_nao_verificado ou caminho_invalido
Ações em massa (publicar, despublicar, duplicar, mudar domínio)
POST /v1/paginas/lote (sessão ou chave: publicar/despublicar exigem pages:publish; duplicar/mudar_dominio, pages:write) aplica uma ação a até 100 páginas. Cada página é processada de forma independente: não há tudo-ou-nada, e a resposta diz o desfecho de cada uma.
simular: true devolve o que aconteceria sem escrever nada (é o que a tela usa para montar a confirmação).
publicar publica a última versão salva, é idempotente (já no ar = ok + sem_mudanca) e pula conteúdo que o contrato recusa (conteudo_invalido). Com rascunho de servidor mais novo que a última versão, rascunho: "pular" (padrão) pula a página; "publicar_salva" publica a última versão salva — o rascunho nunca vira versão nem é descartado.
despublicar tira do ar sem tocar em versões ou rascunho; duplicar cria uma cópia despublicada (-copia); mudar_dominio (dominio_id, e caminho_por_pagina opcional) segue as regras de PUT /v1/paginas/{id}/dominio.
Um id de outra conta volta como erro/nao_encontrada, igual a um id inexistente.
Não existe exclusão em massa. Limite: 12 chamadas/min por conta (429); mais de 100 ids ou nenhum → 422. Cada página processada gera um log de auditoria.
Formulários — o contrato de campos
Um formulário declara seus campos; a captura recusa submissão de campo não declarado (é assim que a base do CRM não fica suja com telefone/Telefone/ tel). O origens[] é a allowlist do modo snippet (CORS por formulário).
Tipos de campo: texto, texto_longo, email, telefone, numero, booleano, opcao (com opcoes[]), data, url, oculto e outros (ver o schema Campo). Um sistema externo também administra formulários por chave, amarrando-os ao seu próprio id (external_id) — veja Integrar um formulário externo. GET /v1/formularios/{id} devolve também o snippet de embed.
Mídia, links curtos, marca, geração por IA
Mídia — galeria por conta. POST /v1/midias recebe multipart/form-data com o arquivo no campo arquivo (dedup por conteúdo: reenviar o mesmo arquivo responde 200 em vez de 201). POST /v1/midias/de-url importa por URL, com defesa contra SSRF. A leitura pública — GET /v1/public/midias/{id} — é anônima: é o src que a página no ar carrega, e exigir sessão quebraria toda página publicada.
Links curtos — /v1/redirects (CRUD) + /v1/redirects/{id}/cliques. O visitante resolve pelo runtime em GET https://sites.asender.net/r/{slug}.
Marca — GET/PUT /v1/configuracoes/marca (logo, favicon, cor primária).
Geração por IA — POST /v1/ia/gerar-pagina produz um rascunho de template a partir de um pedido em linguagem natural. Responde 503 onde o provedor de IA não está configurado (quem não contratou não paga).
Fontes da página
O documento v2 aceita, no estilo da página, duas famílias de fonte: estilo.fonte_titulos (títulos h1–h6) e estilo.fonte_texto (texto corrido). Vazio ou ausente herda do kit de marca; senão o valor tem de ser um destes ids, exatamente assim (minúsculas, sem espaços):
As fontes são auto-hospedadas pelo runtime (/_f/…, cache de um ano): a página publicada não chama nenhum serviço de terceiros. Qualquer outro valor ("Inter", "arial", um nome com espaço, texto com CSS) é recusado na gravação com 422 e código ErrEstiloInvalido, com estilo.fonte_titulos ou estilo.fonte_texto na mensagem de erro. A lista também é o enum desses campos no schema DocV2_estiloPagina da especificação.
Sob /v1/paginas/{id} moram duas mecânicas de desvio, exclusivas entre si (ADR-0014):
A/B (/experimento, /variantes, /ab/promover, /experimento/auto-stop): variantes com peso de 0–100. Um experimento em curso não é editável (409); a leitura do resultado é a analytics GET /v1/analytics/ab.
Roteamento (/roteamento): desvio por regra (geo/device/idioma), até 20 destinos. Salvar roteamento onde já há experimento ativo responde 409.
Captura de lead — os modos de submissão
Um formulário recebe submissão de três formas, e todas caem no mesmo pipeline (validação do contrato de campos → antispam → grava → outbox para o CRM).
Modo API (server-to-server, com API key)
Para o seu backend enviar um lead. Base: https://api.pages.asender.net (sites.asender.net serve igual). Exige chave com forms:write. O sucesso é 201 (novo) ou 200 (deduplicado); o corpo traz duplicada.
POST /v1/leads é o mesmo, com o form_id no corpo em vez de no path, e POST /v1/forms/externo/{externalID}/submissions acha o formulário pelo seu external_id. O corpo completo (id_externo, consentimento, ocorrido_em, origem.url/utm_*) está no passo 3 da integração externa. Os campos são explícitos (campos é um mapa nome→valor) — nada de bind do corpo inteiro (anti mass assignment, §37).
Modo snippet (embutido, anônimo)
O snippet gerado pelo formulário posta em POST /f/{formID}sem credencial: a conta sai do próprio formulário, e a origem tem de estar na allowlist dele (senão 403). Um <form> nativo recebe a tela de agradecimento; um XHR recebe JSON. Sempre responde 200 — inclusive quando a submissão é classificada como spam: aceitar e descartar são indistinguíveis, de propósito (devolver 403 ensinaria o bot a ajustar até passar).
Modo página nativa
Quando o formulário faz parte de uma página servida pelo runtime, o POST da própria página (sites.asender.net/…) alimenta o mesmo pipeline. Nada a integrar — é a página publicada funcionando.
Não-produção nunca entrega efeito externo real (piso 18). O lead capturado segue para o CRM por outbox; use @exemplo.test (ou os TLDs reservados .test/.invalid/.example) em fixtures e testes, nunca um e-mail de pessoa real ou o domínio de um cliente.
Integrar um formulário externo (ex.: blog)
Caso: o seu blog (ou outro sistema) tem um formulário próprio e você quer que cada envio vire lead no Asender — contato no CRM, mensagem na caixa de atendimento, e marketing quando houver opt-in. Tudo server-to-server, em https://api.pages.asender.net, com uma API key. O fluxo é: chave → upsert do formulário → enviar leads.
Não-produção nunca entrega efeito externo real (piso 18). Fora de prd use chave sk_test_… e e-mails sintéticos @exemplo.test (ou os TLDs reservados .test/.invalid/.example) — nunca e-mail de pessoa real nem o domínio de um cliente. Os exemplos abaixo já seguem isso.
1. Crie a chave com os escopos certos
No painel (API keys) ou pela Plataforma:
curl -X POST 'https://api.asender.net/api/tenants/<tenant_id>/api-keys' \
-H 'Authorization: Bearer <token-de-sessão>' \
-H 'Content-Type: application/json' \
-d '{ "name": "blog-formularios", "scopes": ["forms:write", "forms:read"] }'
# → o segredo sk_… aparece UMA vez
forms:write cria/edita/apaga formulários e envia leads; forms:read só lê. Para o fluxo deste guia, forms:write basta; forms:read é útil para conferir o que está no ar.
sk_test_… fora de produção, sk_live_… só em produção.
O segredo vive só no servidor (variável de ambiente, secret manager). Nunca no JavaScript do navegador nem em NEXT_PUBLIC_*. Chave vazada: revogue na Plataforma — para de valer na hora.
2. Faça o upsert do formulário por external_id
PUT /v1/formularios/externo/{external_id} cria o formulário se não existe (201) e o atualiza se já existe (200). É idempotente: rodar de novo com o mesmo corpo não muda nada — pode ir no deploy do blog. Você não precisa guardar o id do Asender; o seu external_id (1–128 caracteres, A-Z a-z 0-9 . _ : -, único na conta) basta.
O PUT substitui a definição inteira: o que não vier no corpo deixa de existir. Mapeamento dos campos do formulário do blog para o tipo do Asender:
Campo do formulário do blog
tipo no Asender
Observação
texto (input text)
texto
área de texto (textarea)
texto_longo
e-mail
email
alimenta o e-mail do contato
telefone (tel)
telefone
alimenta o telefone do contato
número
numero
caixa de seleção única (checkbox)
booleano
valor_marcado define o valor enviado quando marcada
select / radio
opcao
liste os valores em opcoes[]
data
data
hidden
oculto
URL
url
Outras propriedades do campo:
chave — a chave do catálogo de campos nativos (ex.: empresa, cargo) liga o campo ao atributo correspondente do contato no CRM. Sem chave, o campo vira um atributo personalizado com o nome do campo (nunca é descartado).
obrigatorio — recusa o envio sem o campo (400).
Consentimento: exige_consentimento: true faz o envio ser recusado sem consentimento; texto_consentimento e base_legal (consentimento, legitimo_interesse, execucao_contrato, obrigacao_legal) são do formulário. Alternativamente marque um campo booleano com consentimento: true (a caixa de aceite).
Só os campos declarados são aceitos no envio: campo desconhecido é recusado, e é assim que a base do CRM não fica suja.
Para criar sem upsert, POST /v1/formularios aceita o mesmo corpo com external_id e, opcionalmente, o header Idempotency-Key (1–255 caracteres): repetir a mesma chave com o mesmo corpo devolve 201 com o mesmo formulário e Idempotent-Replayed: true; a mesma chave com corpo diferente dá 422. Para conferir: GET /v1/formularios/externo/blog-contato ou GET /v1/formularios?external_id=blog-contato. DELETE /v1/formularios/{id} apaga (lógico) e libera o external_id.
3. Envie os leads, server-to-server
A cada envio no blog, o seu servidor chama (nunca o navegador do visitante):
(Se preferir o id do Asender: POST /v1/forms/{formID}/submissions ou POST /v1/leads com form_id no corpo — mesmo corpo.)
Campo
O que é
campos
mapa nome→valor dos campos declarados no passo 2
id_externo
id desta submissão no seu sistema; é a chave de deduplicação (por formulário). Até 255 caracteres. O header Idempotency-Key serve no lugar
consentimento
true equivale a campos.consentimento = "on"; obrigatório se o formulário exige_consentimento
ocorrido_em
quando o visitante enviou (RFC 3339). Fora da janela — mais de 5 min no futuro ou mais de 365 dias no passado — é recusado (400)
origem
url (a página do blog, http/https, até 2048), utm_source, utm_medium, utm_campaign, utm_term, utm_content, referrer
Retry seguro. Reenvie com o mesmo id_externo quantas vezes precisar: o primeiro vira 201; os seguintes dão 200 com "duplicada": true e o mesmolead_id — nada é gravado duas vezes. Trate 200 e 201 como sucesso.
Backoff. Em 429 espere Retry-After segundos; em 502/503 (core ou registro indisponível) espere com backoff exponencial (ex.: 1 s, 2 s, 4 s… com jitter) e reenvie com o mesmo id_externo. Não retente 400/401/403/404 — são determinísticos (corpo, chave, escopo, formulário). O lead já aceito é durável: o CRM e a caixa de atendimento recebem de forma assíncrona (outbox), com retry próprio.
4. Onde os leads aparecem
CRM — o contato é criado (ou reconhecido por e-mail/telefone) e o lead entra no funil; se a mesma pessoa volta, o lead é requalificado (estágio volta a "novo", com tag e contador) em vez de duplicar.
Caixa de atendimento — o envio vira mensagem na inbox, conversa por contato no canal formulário; reabre quando o lead volta.
Pages — os envios ficam na aba de envios do formulário (histórico de coleta, para auditoria).
Marketing — o contato entra na lista/segmento só se o destino do formulário estiver configurado para isso e houve opt-in (consentimento). Sem consentimento, não há comunicação de marketing.
5. Erros, escopos, limites e LGPD
Envelopes (a spec diz a verdade por processo):
Autoria (/v1/formularios…):
{ "erro": "a chave não tem o escopo forms:write" }
Envio de lead (/v1/leads, /v1/forms/…):
{ "error": { "message": "o formulário exige consentimento" } }
HTTP
Significado nesta integração
400
corpo inválido; campo não declarado/inválido; consentimento exigido ausente; ocorrido_em fora da janela
401
chave ausente, inválida, revogada ou conta inativa
403
a chave não tem o escopo da rota (forms:read/forms:write)
404
formulário inexistente ou de outra conta (não revela existência)
409
external_id ou nome já em uso
422
definição de formulário inválida; Idempotency-Key já usada com outro corpo
429
limite excedido — respeite Retry-After
503
não deu para validar a chave agora — tente de novo com backoff
Limites: 300 requisições por minuto por conta no envio de lead (excesso = 429 com Retry-After) e 64 KB por corpo.
LGPD: quem decide a base legal e o texto do aceite é o formulário (base_legal, texto_consentimento); com exige_consentimento, o envio sem consentimento é recusado. Mande o consentimento que o visitante de fato deu — não o fabrique — e ocorrido_em com o instante real.
Quickstart — integração externa (SDK JS)
Você tem um formulário numa página sua (ex.: um site Next próprio) e quer que as submissões cheguem ao Asender — no CRM e no marketing. Não precisa falar HTTP na mão: o SDK @asender/pages-sdk faz a captura pelos dois caminhos, cada um com a defesa certa.
npm install @asender/pages-sdk
modo
quando
endpoint
credencial
server-to-server
seu backend/route-handler envia o lead
POST /v1/leads
API key sk_*, lida de env no servidor
cliente anônimo
o browser do visitante posta direto
POST /f/{formID}
nenhuma — a defesa é a allowlist de origem do formulário
### A API key sk_* é SEGREDO — nunca vai para o browser A chave é validada a cada chamada e resolve a conta sozinha (um tenant_id no corpo é ignorado). Ela mora só no servidor — variável de ambiente, secret manager — e jamais em bundle de front, NEXT_PUBLIC_* ou localStorage. No SDK, o modo de browser está num entry separado (@asender/pages-sdk/client) que não importa o caminho da chave: por construção, o bundle do navegador não alcança o segredo. Vazou uma chave? Revogue no painel — ela para de valer na hora.
Gerar a API key
As chaves são emitidas pela Plataforma — pela tela de API keys do painel ou por POST https://api.asender.net/api/tenants/{id}/api-keys (ver a Platform API). Use sk_test_… fora de produção e sk_live_… só em produção.
Server-side — route handler do Next (o caso "Bioma")
O formulário da sua página posta no seu endpoint; o seu endpoint (no servidor) chama o Asender com a chave. O visitante nunca vê a chave.
// app/api/lead/route.ts — RODA NO SERVIDOR
import { createServerClient, AsenderHttpError } from "@asender/pages-sdk";
const pages = createServerClient({ apiKey: process.env.ASENDER_API_KEY! });
export async function POST(req: Request) {
const { email, nome, empresa } = await req.json();
try {
const r = await pages.captureLead({
formId: "form_da_bioma",
campos: { email, nome, empresa }, // "empresa" não-mapeado vira custom field
origem: { utm_source: "bioma", utm_medium: "site" },
});
return Response.json({ ok: true, lead_id: r.lead_id, duplicada: r.duplicada });
} catch (e) {
if (e instanceof AsenderHttpError) return Response.json({ erro: e.message }, { status: e.status });
throw e; // rede/validação — não engole
}
}
O corpo enviado ao runtime é { form_id, campos, origem } — campos é um mapa nome→valor, explícito (anti mass assignment). Sucesso é 201 (novo) ou 200 (deduplicado); r.criado traduz isso e r.duplicada vem do corpo.
Client-side — snippet anônimo (sem chave)
Quando o formulário pode postar direto do browser, use o entry /client. Não há API key: autorize a origem da sua página na allowlist do formulário (no painel de Páginas) — o runtime recusa origem fora da lista com 403.
import { createBrowserClient } from "@asender/pages-sdk/client";
const pages = createBrowserClient();
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const campos = Object.fromEntries(new FormData(form) as any) as Record<string, string>;
await pages.submitForm({ formId: "form_da_bioma", campos });
// /f/{formID} SEMPRE responde 200 (aceito ou spam, indistinguível, por desenho)
});
Como os campos viram destino
O que você manda em campos é preservado inteiro. Para cada campo, o formulário aplica seu mapa de destino: um campo mapeado para um alvo canônico (email | telefone | nome) alimenta o campo estruturado do CRM/marketing; um campo não-mapeado vira custom field com o nome do campo — nunca é descartado. Toda submissão é gravada no histórico de coleta para auditoria, antes de seguir aos destinos marcados (CRM, marketing, webhook).
Erros e retry
O SDK não engole erro: AsenderValidationError (entrada inválida, antes da rede), AsenderHttpError (.status, mensagem já extraída do envelope) e AsenderNetworkError (.cause). Ele re-tenta com backoff só em falha de rede e em 429/502/503; nunca em 4xx determinístico (400/401/403/404).
Medir o publicado
Base: https://api.pages.asender.net, com sessão (é o painel quem lê).
Analytics
Relatórios por janela (?de=&ate= RFC3339, padrão 7 dias, teto 92):
/overview, /breakdown (?dimensao=utm_source — dimensão desconhecida responde 400 com a lista aceita), /events, /session/{id}, /traffic-quality, /ab, /heatmap (?lado=).
Tempo real: /live (instantâneo) e /live/stream (SSE).
Pixels
O control plane dos pontos de coleta: GET/POST /v1/pixels (a criação devolve o snippet pronto com a credencial assinada), …/domains (as origens autorizadas a coletar) e …/conversions (as conversões rastreadas).
GET /pixel.js serve o script; POST /collect ingere um lote de eventos de comportamento; POST /replay grava uma jornada. Os três rodam no runtime (sites.asender.net), são anônimos e trazem a credencial do pixel no corpo (o token assinado que a página carrega). São o que o pixel.js chama — você não os invoca à mão. A leitura das jornadas é autenticada: GET /v1/replay/sessions e …/sessions/{id}/events, na base de autoria.
A referência completa de cada rota — parâmetros, corpo, respostas e um curl pronto — está logo abaixo, gerada do código e da spec.
Superfície interna (não pública). O runtime e a autoria conversam por um canal interno de control plane, reservado aos serviços da casa: a definição do formulário, a página publicada para render, o sitemap, a autorização de TLS de domínio próprio e a descarga de contagens. Ele não é documentado aqui porque não é uma superfície de integração: use a sessão (para administrar) ou a API key (para enviar leads).
Referência
Toda operação registrada no código, com o que faz, o que recebe e o que devolve.
_f
GET/_f/{arquivo}sem schema na spec
304 quando o `If-None-Match` bate; senão o bundle, com cache de uma hora e `X-Content-Type-Options: nosniff`.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Exemplo
curl -X GET 'https://api.pages.asender.net/_f/arquivo_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
{slug}
POST/{slug}sem schema na spec
resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.
Onde é usada
POST no caminho da própria página.
Efeitos
os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro form_id. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.
Sem experimento: sessões e conversões por variante na janela (legado). Com experimento (ADR-0093): o relatório estatístico de UM teste — por variante, visitas, conversões, taxa com intervalo de Wilson 95%, chance de ser a melhor (Beta-Binomial), peso configurado e efetivo; o estado (coletando / sem_dados_suficientes / inconclusivo / vencedor_provavel) e a série diária. Conversão = sessão com form_submit; robôs fora.
Onde é usada
tela do experimento.
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
pagina
query
não
recorta o relatório por página; ausente agrega todas.
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
experimento
query
não
variantes
query
não
id:peso separados por vírgula (até 10)
desde
query
não
início do teste
minimo
query
não
prob
query
não
piso
query
não
autobalancear
query
não
Respostas
código
significado
200
ok
400
parâmetros do experimento inválidos
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/ab' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/breakdown
Quebra por dimensão
Onde é usada
tela de origens.
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
dimensao
query
não
dimensão da quebra; dimensão desconhecida devolve 400 COM a lista aceita
pagina
query
não
recorta o relatório por página; ausente agrega todas.
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
limite
query
não
Respostas
código
significado
200
ok
400
dimensão/janela inválidas
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/breakdown' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/events
Eventos brutos
Onde é usada
tela de depuração da instrumentação.
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
pagina
query
não
recorta o relatório por página; ausente agrega todas.
limite
query
não
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
Respostas
código
significado
200
ok
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/events' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/heatmap
Mapa de calor
Onde é usada
tela de mapa de calor.
Efeitos
uma leitura. # As posições são PORCENTAGEM, e não pixel Pixel absoluto é da tela de quem clicou: o mesmo botão sai em x=320 no celular e x=980 no monitor. A célula em porcentagem compara telas diferentes — que é a única pergunta que um mapa de calor responde.
Parâmetros
nome
em
obrigatório
descrição
pagina
query
não
recorta o relatório por página; ausente agrega todas.
lado
query
não
resolução da grade
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
Respostas
código
significado
200
ok
400
lado inválido
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/heatmap' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/live
Instantâneo em tempo real
Onde é usada
cabeçalho da tela ao vivo, no primeiro carregamento.
Efeitos
uma leitura. Existe além do stream porque quem abre a tela precisa ver ALGO antes do primeiro evento chegar. Sem isso, uma conta com movimento baixo mostraria tela vazia por minutos e pareceria quebrada.
Respostas
código
significado
200
ok
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/live' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/live/stream
Stream em tempo real (SSE)
Server-Sent Events do movimento ao vivo.
Onde é usada
tela ao vivo.
Efeitos
mantém uma conexão aberta e uma inscrição no hub. # SSE, e não WebSocket O fluxo é de mão única (servidor → tela) e o cliente é um navegador. SSE atravessa proxy comum, reconecta sozinho e cabe em quatro linhas de JS; WebSocket traria um protocolo inteiro para carregar dado que só desce.
Respostas
código
significado
200
fluxo de eventos
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/live/stream' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/overview
Visão geral do publicado
Onde é usada
tela inicial de analytics.
Efeitos
duas leituras. Funil e atribuição juntos porque a tela mostra os dois lado a lado, e duas requisições poderiam ler janelas diferentes — o total da atribuição não fecharia com o topo do funil, e o número "errado" seria o certo.
Parâmetros
nome
em
obrigatório
descrição
pagina
query
não
recorta o relatório por página; ausente agrega todas.
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
Respostas
código
significado
200
ok
400
janela inválida (RFC3339, teto 92 dias)
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/overview' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/relatorio
Relatório filtrado (totais, série diária e dimensão)
Atribuição por SESSÃO (primeiro valor preenchido da visita): a conversão que acontece depois da página de entrada herda a origem dela. A comparação com o período anterior é esta mesma rota com a janela anterior.
Onde é usada
aba "Analytics" do formulário no painel.
Efeitos
nove leituras sob o tenant. Um formulário de outra conta casa zero linha na RLS: o relatório sai zerado, que é o desfecho correto (a existência do form não vaza nem por 403).
Parâmetros
nome
em
obrigatório
descrição
dimensao
query
não
dimensão das linhas; desconhecida devolve 400 com a lista
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
curl -X GET 'https://api.pages.asender.net/v1/analytics/relatorio' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/session/{id}
Eventos de uma sessão
Onde é usada
tela de sessão — o que aquela visita fez, em ordem.
Efeitos
uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id de sessão é opaco e gerado no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo de existência de sessão alheia.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
400
sessão inválida
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/session/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/analytics/traffic-quality
Qualidade do tráfego
Onde é usada
tela de qualidade — "esse tráfego pago é gente?".
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
de
query
não
início da janela (RFC3339). Ausente usa os últimos 7 dias; teto de 92 dias.
ate
query
não
fim da janela (RFC3339).
Respostas
código
significado
200
ok
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/analytics/traffic-quality' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/formularios/{formID}/analytics
Funil e conversão de um formulário
Visualizações e inícios contam SESSÕES únicas; envios vem das submissões gravadas (autoritativo). taxa_conversao = envios / visualizacoes; abandono = (inicios - sessões com envio_ok) / inicios. Série diária no fuso pedido, com zeros nos dias vazios.
Onde é usada
aba "Analytics" do formulário no painel.
Efeitos
nove leituras sob o tenant. Um formulário de outra conta casa zero linha na RLS: o relatório sai zerado, que é o desfecho correto (a existência do form não vaza nem por 403).
Parâmetros
nome
em
obrigatório
descrição
formID
path
sim
de
query
não
RFC3339 ou AAAA-MM-DD (padrão: 30 dias atrás)
ate
query
não
RFC3339 ou AAAA-MM-DD (padrão: agora; máx. 400 dias)
fuso
query
não
Respostas
código
significado
200
relatório do funil
400
parâmetro inválido
401
sem sessão/conta
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/formID_AQUI/analytics' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/relatorios/visoes
Listar as visões salvas da conta
Onde é usada
tela de domínios.
Efeitos
uma leitura.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/relatorios/visoes' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/relatorios/visoes
Salvar o recorte atual como visão
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Respostas
código
significado
201
criada
400
corpo inválido (inclui campo desconhecido, ex. tenant_id)
401
Sem sessão / sem tenant na sessão.
409
já existe visão com esse nome (sem diferenciar maiúscula)
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
304 quando o `If-None-Match` bate; senão o bundle, com cache de uma hora e `X-Content-Type-Options: nosniff`.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Exemplo
curl -X GET 'https://api.pages.asender.net/asender-sw.js' \
-H 'Authorization: Bearer SEU_TOKEN'
campos-nativos
GET/v1/campos-nativossem schema na spec
GET /v1/campos-nativos → `{campos, grupos, paises}`.
Onde é usada
construtor de formulário (paleta de campos nativos, seletor de país) e o diálogo de destinos (alvos do mapa). Sessão do painel exigida pela rota.
Efeitos
nenhum — lê o catálogo embutido. Sem tenant na pergunta: o catálogo é o mesmo para toda conta. O 401 sem conta na sessão é só coerência com as rotas vizinhas.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/campos-nativos' \
-H 'Authorization: Bearer SEU_TOKEN'
Captura de leads
POST/f/{formID}
Enviar uma submissão pelo snippet (anônimo)
Modo embutido em site de terceiro. Sem credencial: a conta sai do próprio formulário, e a origem tem de estar na allowlist DELE (senão 403). Sempre responde 200 — inclusive quando a submissão é classificada como spam (aceitar e descartar são indistinguíveis, por desenho). Um <form> nativo (Accept: text/html) recebe a tela de agradecimento; um XHR recebe o JSON. Servido no runtime, com CORS por formulário. O OPTIONS é o preflight.
Onde é usada
rota pública, chamada de outro domínio pelo navegador.
Efeitos
os do pipeline. Aqui não há API key: quem chama é o navegador do visitante, e uma key no snippet seria segredo no cliente (§13.4). A defesa é a allowlist de origem, verificada NO SERVIDOR — o header Origin é posto pelo navegador e não pode ser forjado por script da própria página.
Obter o carimbo de tempo anti-spam do snippet (anônimo)
Chamado pelo script do snippet ao carregar. Devolve um carimbo assinado (HMAC) do instante do pedido, que o snippet reenvia no campo oculto _asender_carimbo do POST /f/{formID}; o runtime mede o tempo de preenchimento com o PRÓPRIO relógio. Mesma allowlist de origem do POST (senão 403, sem CORS) e Cache-Control: no-store. Os campos _asender_website (honeypot), _asender_carimbo e _asender_interacao são reservados: lidos pelo anti-spam e nunca gravados como dado do lead. Sem carimbo, o tempo é tratado como não medido (sem penalidade).
Onde é usada
chamado pelo formulario_antispam.js ao carregar o snippet (cross-origin, com Origin) e pela página nativa (same-origin, sem Origin).
Efeitos
nenhum além da resposta — nada é gravado. # Por que conferir a origem, se um bot forja o cabeçalho Fora do navegador o Origin é forjável, e a allowlist não detém bot aqui (nem no POST). Ela mantém a rota com a MESMA política do envio: um site que não pode postar no formulário também não lê o carimbo dele. no-store porque um carimbo servido de cache mediria o tempo desde o primeiro visitante.
Parâmetros
nome
em
obrigatório
descrição
formID
path
sim
Respostas
código
significado
200
carimbo emitido
403
origem não autorizada para este formulário
404
formulário não encontrado ou carimbo desligado
503
não consegui resolver a conta do formulário agora
Exemplo
curl -X GET 'https://api.pages.asender.net/f/formID_AQUI/carimbo' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/f/{formID}/endereco
Consultar o endereço de um CEP para o widget de endereço (anônimo)
Chamado pelo script do snippet quando o visitante completa o CEP. O runtime confere o formulário (tem endereço? aceita o país? o CEP tem o formato?) e consulta o asender_plataforma_api com a credencial de serviço — o core nunca fica exposto. Mesma política de origem do carimbo; teto de 0.5/s (rajada 10) por formulário × IP e 1/s (rajada 30) por IP. Nenhuma falha trava o formulário: o widget fica manual.
Onde é usada
formulario_endereco.js do snippet e da página.
Efeitos
uma ida ao core no miss do cache. # Respostas 200 endereço; 404 sem consulta ligada, formulário sem endereço ou CEP não encontrado; 403 origem recusada; 422 país/CEP inválidos; 429 teto; 503 core fora. Nenhuma delas trava o formulário: o widget fica manual.
Parâmetros
nome
em
obrigatório
descrição
formID
path
sim
pais
query
sim
cep
query
sim
Respostas
código
significado
200
endereço encontrado (sem número e complemento)
403
origem não autorizada para este formulário
404
CEP não encontrado, formulário sem endereço ou consulta desligada
422
país fora dos aceitos ou CEP fora do formato do país
429
consultas demais (Retry-After)
503
consulta indisponível agora (o endereço fica manual)
Exemplo
curl -X GET 'https://api.pages.asender.net/f/formID_AQUI/endereco' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/f/{formID}/eventos
Registrar eventos do funil do formulário embutido (anônimo)
Chamado pelo script do snippet via navigator.sendBeacon (corpo text/plain com JSON, para não disparar preflight). Tipos: impressao, visualizacao, inicio, campo, envio_tentativa, envio_ok, envio_erro. Mesma política de origem do carimbo; limite por formulário × IP. O IP não é gravado (o país vem do GeoIP). Alimenta GET /v1/formularios/{formID}/analytics.
Igual a POST /v1/leads, com o formID (id do Asender) no path. Exige API key com forms:write.
Onde é usada
rota autenticada do runtime.
Efeitos
os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro.
Parâmetros
nome
em
obrigatório
descrição
formID
path
sim
Idempotency-Key
header
não
Alternativa ao `id_externo` do corpo como chave de deduplicação (por formulário).
Respostas
código
significado
200
duplicada — o `id_externo` (ou `Idempotency-Key`) já foi recebido neste formulário; nada de novo gravado e o `lead_id` é o do envio original
201
lead criado
400
recusado: corpo inválido, campo não declarado ou inválido, consentimento exigido ausente, `ocorrido_em` fora da janela
401
Sem sessão / credencial ausente ou inválida.
403
A chave não tem o escopo `forms:write`.
404
formulário não encontrado (ou de outra conta)
429
limite por conta excedido (300 por minuto por padrão). Aguarde `Retry-After` segundos.
502
não consegui registrar agora; tente de novo com o mesmo `id_externo`
Enviar uma submissão pelo id externo do formulário (modo API)
Igual a POST /v1/forms/{formID}/submissions, mas o formulário é achado pelo external_id que você definiu na autoria — você não precisa guardar o id do Asender. Exige API key com forms:write; formulário inexistente (ou de outra conta) responde 404.
Onde é usada
rota do modo API, para integradores que guardam o id deles, não o nosso.
Efeitos
uma chamada ao pages_api para resolver; depois os do pipeline. 404 quando o pages_api não conhece o id (para a conta da chave); 503 quando não dá para perguntar — recarregar resolve, e o lead não é aceito às cegas.
Parâmetros
nome
em
obrigatório
descrição
externalID
path
sim
`external_id` do formulário
Idempotency-Key
header
não
Alternativa ao `id_externo` do corpo como chave de deduplicação (por formulário).
Respostas
código
significado
200
duplicada — o `id_externo` (ou `Idempotency-Key`) já foi recebido neste formulário; nada de novo gravado e o `lead_id` é o do envio original
201
lead criado
400
recusado: corpo inválido, campo não declarado ou inválido, consentimento exigido ausente, `ocorrido_em` fora da janela
401
Sem sessão / credencial ausente ou inválida.
403
A chave não tem o escopo `forms:write`.
404
formulário não encontrado (ou de outra conta)
429
limite por conta excedido (300 por minuto por padrão). Aguarde `Retry-After` segundos.
502
não consegui registrar agora; tente de novo com o mesmo `id_externo`
Modo server-to-server. Exige API key com forms:write; a conta é resolvida da chave e o form_id vem no corpo. Servido no runtime, mas alcançável em https://api.pages.asender.net (e em https://sites.asender.net, igual). Sucesso é 201 (novo) ou 200 (duplicado). Reenviar com o mesmo id_externo é seguro (retry sem duplicar). Limite: 300 requisições por minuto por conta e 64 KB por corpo. Fora de produção use sk_test_… e e-mails @exemplo.test.
Onde é usada
rota autenticada do runtime.
Efeitos
os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro.
Parâmetros
nome
em
obrigatório
descrição
Idempotency-Key
header
não
Alternativa ao `id_externo` do corpo como chave de deduplicação (por formulário).
Respostas
código
significado
200
duplicada — o `id_externo` (ou `Idempotency-Key`) já foi recebido neste formulário; nada de novo gravado e o `lead_id` é o do envio original
201
lead criado
400
recusado: corpo inválido, campo não declarado ou inválido, consentimento exigido ausente, `ocorrido_em` fora da janela
401
Sem sessão / credencial ausente ou inválida.
403
A chave não tem o escopo `forms:write`.
404
formulário não encontrado (ou de outra conta)
429
limite por conta excedido (300 por minuto por padrão). Aguarde `Retry-After` segundos.
502
não consegui registrar agora; tente de novo com o mesmo `id_externo`
304 quando o `If-None-Match` bate; senão o bundle, com cache de uma hora e `X-Content-Type-Options: nosniff`.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Exemplo
curl -X GET 'https://api.pages.asender.net/chat.js' \
-H 'Authorization: Bearer SEU_TOKEN'
Coleta
POST/collect
Ingerir um lote de eventos de comportamento (anônimo, credencial de pixel)
Chamado pelo pixel.js no navegador do visitante. A credencial (token assinado do pixel) vai no corpo. Responde 204; 403 para credencial/origem inválida, 429 para excesso. Servido no runtime.
Onde é usada
rota pública, a de maior volume do sistema depois da própria página.
Efeitos
publica no broker; NUNCA escreve no banco. # Responde 204 quase sempre, e isso é deliberado Credencial inválida é 403 (o cliente precisa saber que aquele HTML está velho). Fora isso — evento inválido, tipo desconhecido, lote parcialmente ruim — a resposta é 204 e o que dava para aproveitar foi publicado. O pixel roda no browser de terceiros: transformar um evento malformado em erro visível não conserta nada e enche o console de quem só quer ver a página.
Devolve o pixel.js a ser carregado pela página publicada. Servido no runtime, com ETag.
Onde é usada
HTML publicado antes do endereço versionado (ScriptVersionado) e o snippet do pixel de terceiros, que não pode trocar de endereço a cada deploy.
Efeitos
escreve o script. Cache de 1 hora com ETag: o script muda com o deploy, e revalidar de hora em hora custa um 304. immutable seria errado — o conteúdo muda no mesmo caminho. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Respostas
código
significado
200
o script
304
não modificado (ETag)
Exemplo
curl -X GET 'https://api.pages.asender.net/pixel.js' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/r/{slug}
Resolver um link curto (anônimo)
Redireciona ao destino do link e conta o clique. Servido no runtime.
Onde é usada
rota pública do runtime, nos hosts da conta (substitui o Redirect.Servir da 012 no server.go).
Parâmetros
nome
em
obrigatório
descrição
slug
path
sim
Respostas
código
significado
302
redireciona ao destino
404
link inexistente ou inativo
Exemplo
curl -X GET 'https://api.pages.asender.net/r/slug_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/r/{slug}
Resolver um link curto (anônimo)
Redireciona ao destino do link e conta o clique. Servido no runtime.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Parâmetros
nome
em
obrigatório
descrição
slug
path
sim
Respostas
código
significado
302
redireciona ao destino
404
link inexistente ou inativo
Exemplo
curl -X GET 'https://api.pages.asender.net/r/slug_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/r/{slug}
Resolver um link curto (anônimo)
Redireciona ao destino do link e conta o clique. Servido no runtime.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Parâmetros
nome
em
obrigatório
descrição
slug
path
sim
Respostas
código
significado
302
redireciona ao destino
404
link inexistente ou inativo
Exemplo
curl -X GET 'https://api.pages.asender.net/r/slug_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
Domínios
GET/v1/cotas
Uso e teto de domínios da conta, pelo plano
pages_subdominios = grátis (*.apages.xyz) + subdomínios próprios; pages_dominios_principais = domínios próprios apex. limite: null = ilimitado. O teto vem do plano (plataforma, cache 60 s); sem resposta dela, 503 — nunca "0 de 0". Tenant da sessão, nunca da query.
Onde é usada
medidor de cota da tela de domínios.
Efeitos
uma leitura na plataforma (cache 60 s) e uma no banco.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
503
plataforma indisponível para conferir o plano
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/cotas' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/dominios
Listar domínios da conta
Sessão ou API key com pages:read. O domínio padrão da conta (apages.xyz, criado automaticamente) vem primeiro e traz padrao: true; os demais seguem com padrao: false.
Onde é usada
tela de domínios.
Efeitos
uma leitura.
Respostas
código
significado
200
ok (o padrão primeiro)
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/dominios' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/dominios
Adicionar domínio (gera o TXT de posse)
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Respostas
código
significado
201
criado
401
Sem sessão / sem tenant na sessão.
402
limite do plano atingido para a classe do domínio
409
host já cadastrado
422
host inválido, reservado, sufixo público ou sob o domínio dos grátis
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
204
removido
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Diz se a zona está na Cloudflare (NS / _domainconnect), se o botão "Conectar com Cloudflare" (Domain Connect) está disponível e se há token guardado. Ritmo: 60/min por conta. Domínio de outra conta = 404.
Onde é usada
cartão do domínio na tela de domínios.
Efeitos
consultas DNS; no máximo um GET (cacheado) à Cloudflare.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
estado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
429
muitas tentativas
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/dominios/id_AQUI/cloudflare' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/dominios/{id}/cloudflare/aplicar
Aplicar na Cloudflare os registros do Asender
Com o token guardado, cria ou atualiza SÓ os registros do Asender — idempotente, nunca apaga registro alheio, proxied:false em CNAME/A/AAAA. Cada aplicação vai para a trilha de auditoria (também quando falha no meio). Ritmo: 10/min.
Onde é usada
botão "Aplicar registros na Cloudflare". Depois, a tela chama o verificar de sempre.
Efeitos
chamadas à API da Cloudflare em nome do cliente; uma linha de auditoria.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
relatório por registro
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
sem token guardado (ou token ilegível — cole de novo)
Monta a URL da tela de consentimento da Cloudflare para o template do Asender, só com os grupos que faltam, ASSINADA (RSA-SHA256). retorno é o caminho da tela de domínios (/t/<conta>/dominios); o host de volta vem do servidor. Na volta (?cloudflare=<id>) a tela só chama a verificação. Ritmo: 10/min por conta.
Onde é usada
botão "Conectar com Cloudflare".
Efeitos
consultas DNS; um GET cacheado à Cloudflare; uma linha de auditoria. # O retorno não carrega state A Cloudflare não suporta state (doc oficial). Por isso o retorno NÃO faz escrita alguma: a tela só chama o verificar de sempre, que é idempotente e autenticado — forjar um retorno não dá a ninguém nada além de uma verificação.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
URL
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
Domain Connect indisponível para o domínio (ver `motivo`) ou nada a aplicar
Valida o token na API da Cloudflare (a zona do domínio tem de estar visível e o DNS legível), cifra (AES-256-GCM) e guarda. O token nunca é devolvido. Ritmo: 10/min.
Onde é usada
formulário "usar token" do cartão do domínio.
Efeitos
até N+1 GETs à Cloudflare; uma escrita; uma linha de auditoria. O token NUNCA volta em resposta nem em log — nem cifrado.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
estado novo
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
422
formato inválido
429
muitas tentativas
503
Cloudflare indisponível ou cifra não configurada neste ambiente
Marca uma página, já publicada ou não, como a home deste domínio (migration 029) — GET https://host/ passa a resolver nela. A página precisa ser do MESMO tenant e ter dominio igual ao host deste domínio; não publicada é aceita (fica publicada:false na resposta, e / continua 404 até a pessoa publicar).
Onde é usada
tela de domínios (botão "definir página inicial").
Efeitos
uma escrita. # Os dois 404 são indistinguíveis de propósito "Domínio não encontrado" cobre id inexistente E id de outra conta; "página não encontrada" cobre id inexistente, apagada E de outra conta. Distinguir qualquer um desses pares confirmaria a um tenant que aquele id existe na plataforma, atribuído a alguém — o mesmo raciocínio do 404 de Verificar e Apagar (dominios.go).
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
domínio com a home atualizada
400
Parâmetro de query fora do conjunto aceito.
401
Sem sessão / sem tenant na sessão.
404
domínio OU página não encontrados (ids distintos e indistinguíveis de outra conta)
422
a página existe (e é do tenant) mas não é deste domínio
Estado da coleta de inscrições de push neste domínio. Sem configuração responde {"ligado":false}. Domínio de outra conta = 404.
Onde é usada
tela de A/B do painel.
Efeitos
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
estado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/dominios/id_AQUI/push' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/dominios/{id}/push
Ligar ou configurar a coleta de Web Push do domínio
Grava só valores PÚBLICOS emitidos pela ferramenta Push (GET /v1/push/site-config da asender_push_api): chave VAPID pública, chave de site pk_push_… e a base da API, mais o convite. Com ligado:true, as páginas do domínio servem /asender-sw.js e o convite de inscrição. Exige papel de editor. O domínio precisa estar verificado (409).
Onde é usada
botão salvar do editor.
Efeitos
cria uma versão.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
configuração gravada
400
Parâmetro de query fora do conjunto aceito.
401
Sem sessão / sem tenant na sessão.
403
sem papel de editor
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Verificar os 3 passos (TXT, apontamento e certificado)
Confere a posse (TXT, se ainda não provada), o apontamento (CNAME/A para a borda) e, com os dois ok, faz um handshake TLS que aquece o certificado. Responde 200 com o estado de CADA passo (um passo reprovado vem com ok:false e o motivo), e 503 só quando o DNS não respondeu a nada.
Onde é usada
botão "verificar agora" da tela de domínios.
Efeitos
consultas DNS, um handshake TLS com a nossa borda e escritas. # 200 com o estado de cada passo, e não 422 no primeiro que falha A tela é em passos: o cliente publica o TXT e o CNAME de uma vez, e precisa ver qual dos dois ainda falta. Recusar com 422 no TXT esconderia o estado do apontamento — que foi exatamente a queixa ("só tem opção do TXT"). O 503 continua para quando NADA pôde ser consultado: aí a resposta certa é "tente de novo", não um estado. # A posse já provada não é reconsultada O cliente costuma apagar o TXT depois de verificar. Reconsultar transformaria essa limpeza num erro vermelho no passo 1 de um domínio que está no ar.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
estado dos 3 passos
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Nasce verificado e pronto (posse da plataforma, TLS pelo certificado curinga). Conta no recurso pages_subdominios (que conta SÓ os grátis; host do cliente, como bio.cliente.com.br, conta em pages_dominios_principais). base opcional escolhe a base grátis (vazia = a padrão). Liberar é DELETE /v1/dominios/{id}. Até 10 reservas/trocas por hora por conta (429).
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Respostas
código
significado
201
reservado
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
402
cota do plano esgotada
409
nome em uso (ou em quarentena)
422
rótulo recusado (com `motivo`)
429
reservas demais na última hora
503
plataforma indisponível para conferir o plano (fail-closed)
Listar as bases em que um subdomínio grátis pode ser reservado
As bases grátis da plataforma (apages.xyz e as que forem disponibilizadas). padrao é a primária, usada quando base não é enviada.
Onde é usada
o seletor de base do bloco "Domínio grátis" (aparece só se há >1).
Respostas
código
significado
200
bases e a padrão
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/subdominios-gratis/bases' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/subdominios-gratis/disponivel
Conferir se um nome grátis (<rotulo>.<base>) pode ser reservado
Rótulo DNS de 3 a 63 caracteres [a-z0-9-], sem punycode; fora dos nomes reservados da plataforma e das marcas/termos sensíveis (inclusive imitações como 1tau/paypa1). em_uso não diz de qual conta. Teto de 30 consultas por minuto por conta (429).
Onde é usada
o campo "domínio grátis" da tela, enquanto a pessoa digita.
Efeitos
no máximo uma leitura. 429 acima de 30 consultas/min por conta — sem teto, a rota vira enumerador de quais nomes existem na plataforma.
Parâmetros
nome
em
obrigatório
descrição
rotulo
query
sim
base
query
não
Base grátis (de GET /v1/subdominios-gratis/bases); vazia = a padrão. Fora da lista: 422 base_desconhecida.
Respostas
código
significado
200
resposta (disponível ou o motivo)
401
Sem sessão / sem tenant na sessão.
422
base desconhecida (`motivo: base_desconhecida`)
429
consultas demais
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/subdominios-gratis/disponivel' \
-H 'Authorization: Bearer SEU_TOKEN'
f
GET/f/{formID}/embed.jssem schema na spec
devolve o loader com o HTML atual do formulário embutido como string JSON.
Onde é usada
<script src=".../f/{id}/embed.js" async> colado no site do cliente (o snippet_js do painel).
Efeitos
nenhum além da resposta. # Sem checagem de origem aqui <script src> não manda Origin, e o HTML não é segredo: é o mesmo que o modo HTML entrega para colar à vista. A política de origem continua onde age — no carimbo e no POST, que o HTML injetado chama. # JSON como literal JS json.Marshal escapa <, >, & e U+2028/U+2029: o HTML não fecha o <script> e não quebra a string. # Cache HTTP com revalidação (forms-rapidos, 2026-10-01) max-age=60, stale-while-revalidate=600: o navegador reusa a resposta por 1 min sem perguntar nada (a maioria das visitas de um mesmo site, no mesmo minuto, não paga NADA); depois disso, por até 10 min, ainda serve o que tem na hora enquanto revalida em paralelo (quem não implementa stale-while-revalidate cai para a validação normal, sem quebrar). Não é max-age longo e cego: o formulário muda quando o dono publica, e 1 min é curto o bastante para "editei e já vale" continuar parecendo imediato. public porque o HTML não é segredo (§ "sem checagem de origem" acima) nem varia por visitante — é o mesmo snippet pra qualquer um que peça aquele formID. ETag é o hash dos bytes já montados (embedCache, por formID) — forte (mesmo conteúdo byte a byte, mesmo valor) e barato de comparar: o Go só lê o If-None-Match do pedido, nunca remonta o snippet para decidir o 304. # Comparação FRACA, nunca == (achado em hml, 2026-10-01) A borda (nginx) comprime o embed.js on-the-fly e, ao fazer isso, REBAIXA o ETag forte que o runtime manda para fraco (W/"…") — comportamento padrão do módulo de gzip dela, que não garante equivalência byte-a-byte entre o corpo comprimido e o cru. O navegador devolve esse W/"…" no If-None-Match da revisita; comparar com == contra o etag FORTE deste handler nunca batia, e toda revisita baixava o corpo inteiro de novo — a compressão/cache da rodada 1/2 chegavam a produção, só a comparação estava errada. ifNoneMatchCasa (RFC 9110 §13.1.2) ignora o prefixo W/ de QUALQUER lado — é a única comparação válida para If-None-Match (a forte só existiria para If-Match/Range), então o mesmo código já cobre um Caddy que algum dia fizer o mesmo rebaixamento.
Exemplo
curl -X GET 'https://api.pages.asender.net/f/formID_AQUI/embed.js' \
-H 'Authorization: Bearer SEU_TOKEN'
favicon.ico
GET/favicon.icosem schema na spec
redireciona (302) para o favicon do site (o de SEO, senão o da marca) quando o dono do host configurou um https; senão para o padrão `/favicon.svg`.
Onde é usada
rota pública, pedida pelo navegador e pelo buscador por convenção.
Efeitos
uma leitura no asender_pages_api (quando há fonte). 302 e não 301: o favicon do site muda quando o cliente troca a marca, e um 301 ficaria gravado no navegador do visitante para sempre. Falha na consulta cai no padrão — favicon nunca vale um erro.
Exemplo
curl -X GET 'https://api.pages.asender.net/favicon.ico' \
-H 'Authorization: Bearer SEU_TOKEN'
favicon.svg
GET/favicon.svgsem schema na spec
o SVG fixo, cacheável por um dia.
Onde é usada
rota pública; é o <link rel="icon"> da página sem favicon (seo.go do renderizador).
Efeitos
nenhum.
Exemplo
curl -X GET 'https://api.pages.asender.net/favicon.svg' \
-H 'Authorization: Bearer SEU_TOKEN'
formularios
GET/v1/formularios/{formID}/enviossem schema na spec
GET /v1/formularios/{formID}/envios?limite=&cursor=&desde=&ate=.
Onde é usada
a tela de leads da ferramenta de Páginas/Formulários.
Efeitos
uma consulta; uma abertura de AES-GCM por linha; um registro de acesso a PII no log. Devolve {"envios": [...], "proximo_cursor": ""} — cursor vazio significa que acabou. A alternativa (omitir o campo no fim) obrigaria o cliente a distinguir ausente de vazio, e é o tipo de sutileza que vira laço infinito na tela.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/formID_AQUI/envios' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/formularios/{formID}/envios.csvsem schema na spec
GET /v1/formularios/{formID}/envios.csv?desde=&ate=.
Onde é usada
botão "exportar" da tela de leads.
Efeitos
N consultas (a exportação pagina por dentro), uma abertura de AES-GCM por linha, um registro de acesso a PII no log. # Escreve direto no ResponseWriter, sem montar o arquivo na memória Um CSV de 10.000 leads com campos livres passa de 10 MB. Montá-lo num buffer para só então enviar duplicaria isso por requisição concorrente, e o pico aconteceria justamente quando várias pessoas exportam no mesmo dia (o dia seguinte a uma campanha). O encoding/csv escreve incrementalmente. O custo dessa escolha é honesto e está registrado em falharNoMeio: depois do primeiro byte não há mais como trocar o status.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/formID_AQUI/envios.csv' \
-H 'Authorization: Bearer SEU_TOKEN'
Formulários
GET/v1/formularios
Listar formulários
Aceita sessão ou API key com forms:read. Com ?external_id= devolve só o formulário com esse id externo (lista vazia se não houver).
Onde é usada
tela de domínios.
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
external_id
query
não
filtra pelo id do formulário no seu sistema
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/formularios
Criar formulário
Sessão ou API key com forms:write. Envie external_id para amarrar o formulário ao do seu sistema (colisão → 409). Com o header Idempotency-Key, repetir a chamada (mesma chave e mesmo corpo) devolve 201 com o MESMO formulário e Idempotent-Replayed: true; a mesma chave com outro corpo responde 422. Para criar-ou-atualizar sem guardar o id do Asender, prefira PUT /v1/formularios/externo/{external_id}.
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Parâmetros
nome
em
obrigatório
descrição
Idempotency-Key
header
não
Chave de idempotência da criação (1–255 caracteres, única por conta). Repetir a mesma chave com o MESMO corpo devolve 201 com o MESMO formulário e o header `Idempotent-Replayed: true`; com corpo diferente, 422.
Respostas
código
significado
201
criado (ou replay idempotente — então vem `Idempotent-Replayed`)
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
409
nome ou `external_id` já em uso
422
definição inválida, ou `Idempotency-Key` já usada com outro corpo
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Sessão ou API key com forms:write. Apagar é lógico: o external_id fica livre para um novo formulário.
Onde é usada
tela de domínios.
Efeitos
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
204
apagado
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Obter formulário (com o snippet de embed e o rascunho do editor)
Sessão ou API key com forms:read. Formulário de outra conta responde 404.
Onde é usada
tela de A/B do painel.
Efeitos
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/formularios/{id}
Atualizar (publicar) formulário
Substitui o recurso inteiro — inclusive o layout — e ZERA o rascunho do editor na mesma escrita. É o "Publicar" do editor. Sessão ou API key com forms:write. external_id ausente mantém o atual; presente e diferente troca, se estiver livre (409 se em uso).
Onde é usada
edição.
Efeitos
escreve a linha.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
nome ou `external_id` já em uso
422
definição inválida
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Listar destinos do formulário (crm/marketing/webhook)
Os destinos para onde a submissão é copiada, com o mapa de campos de cada um. Anti-IDOR: um formulário de outra conta responde 404.
Onde é usada
seção "Destinos" da tela do formulário.
Efeitos
uma leitura, com checagem de posse (anti-IDOR).
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok — os destinos (com `condicao_erro` nos quebrados) e se a conta tem o Marketing. Formulário com caixa de aceite numa conta com Marketing ganha, UMA vez, o destino Marketing com a condição "caixa marcada" (preset).
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/id_AQUI/destinos' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/formularios/{id}/destinos
Substituir a lista de destinos do formulário
SUBSTITUI a lista inteira (como campos/origens). Valida tipo, mapa e a CONDIÇÃO de cada destino contra os campos atuais (campo inexistente, operador do tipo errado, opção que não existe → 422); Marketing só com "aceite marcado"; webhook exige URL http(s). O SSRF do webhook é conferido no runtime, no instante do despacho. Destino integracao NOVO ou ALTERADO é conferido no serviço de Integrações (instalação existe e é da conta; opções valem) — 422 com codigo (IntegracaoRepetida, LimiteIntegracoes, IntegracaoInvalida, IntegracaoInexistente, OpcaoInvalida) e local; serviço fora → 503 IntegracoesIndisponivel só nesse caso.
Onde é usada
seção "Destinos" da tela do formulário, ao salvar.
Efeitos
apaga e regrava os destinos, atômico. A validação mora no domínio (destino.ValidarListaComCampos): tipo conhecido, mapa com alvos válidos, webhook com URL http(s), condições contra os campos do formulário e o Marketing só com "aceite marcado". O SSRF do webhook é conferido no runtime, no instante do despacho — aqui a checagem é de FORMA, a que dá mensagem clara para quem configura.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
422
destino ou condição inválidos (integracao: + codigo e local)
503
IntegracoesIndisponivel — só com destino integracao novo/alterado
Guarda o corpo do PUT do formulário SEM publicá-lo — o formulário no ar não muda. Só a FORMA é conferida (campos desconhecidos → 400, teto de 512 KiB → 413): rascunho pode estar incompleto; as regras rodam no publicar. Anti-IDOR: formulário de outra conta responde 404.
Onde é usada
botão "Salvar rascunho" do editor-página (e o salvamento automático).
Efeitos
escreve rascunho; o formulário no ar NÃO muda. # Só a FORMA é conferida Rascunho existe para guardar trabalho pela metade — um campo sem rótulo, uma lista sem opção. Recusá-lo pelas regras de publicação faria a pessoa perder o que fez justamente quando ainda não terminou. A borda confere que o corpo é o do PUT (nenhum campo desconhecido, tipos certos, dentro do teto) e grava a forma DECODIFICADA re-serializada: chave estranha não chega ao banco. Toda a regra de domínio roda no PUBLICAR.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
salvo
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Sessão ou API key com forms:read. Procura entre os formulários não apagados da conta.
Onde é usada
integrações por chave (forms:read) e sessão.
Efeitos
uma leitura.
Parâmetros
nome
em
obrigatório
descrição
external_id
path
sim
id do formulário no sistema do integrador (campo `external_id`).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/formularios/externo/external_id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/formularios/externo/{external_id}
Criar ou atualizar formulário pelo id externo (upsert idempotente)
Sessão ou API key com forms:write. Se não existe formulário com esse external_id na conta, cria (201); se existe, SUBSTITUI a definição (200), como o PUT /v1/formularios/{id}. Rodar de novo com o mesmo corpo não muda nada. O external_id do path é o que vale.
Onde é usada
integrações por chave (forms:write) e sessão.
Efeitos
escreve a linha; atualizar zera o rascunho do editor.
Parâmetros
nome
em
obrigatório
descrição
external_id
path
sim
id do formulário no sistema do integrador (campo `external_id`).
Respostas
código
significado
200
atualizado
201
criado
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
409
nome já em uso
422
definição inválida
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Produz um rascunho a partir de um pedido em linguagem natural — SEMPRE um documento v2 (sem cabeçalho forçado, sem form_id: o formulário é escolhido no editor). O html que o modelo devolver é REMOVIDO e listado em removidos; item fora do contrato é descartado. NÃO grava nada. Responde 503 quando o provedor de IA não está configurado neste ambiente.
Onde é usada
botão "gerar com IA" do editor.
Efeitos
uma chamada externa; NÃO grava nada. # Não grava: devolve o rascunho Gravar direto faria o modelo publicar na conta do cliente. O que sai daqui é uma sugestão que a pessoa vê no editor e salva se quiser — a versão continua nascendo do gesto dela. # Sem provedor, a rota DEGRADA 503 e não 500: é configuração ausente, e a distinção separa "o produto não tem essa função ligada" de "o produto quebrou". O resto do serviço segue de pé — nenhuma página deixa de ser servida porque não há chave de IA (piso 10).
Nome, destino, slug ou código (sem caixa, sem curinga).
pagina_id
query
não
Só os links criados pelo editor desta página.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/links' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/links
Criar link (código curto + UTM + parâmetros + repasse)
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Respostas
código
significado
200
o editor já tinha criado este link (origem pagina, mesmo bloco e destino)
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
204
apagado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/links/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/links/{id}
Editar link (código, origem e contagem não mudam)
Onde é usada
edição.
Efeitos
escreve a linha.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Padrão: hoje (America/Sao_Paulo). Período máximo 366 dias.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
422
período inválido
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/links/id_AQUI/relatorio' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/links/previa
Prévia da URL final (mesma regra do redirect)
Onde é usada
editor de link do painel (prévia ao vivo, enquanto a pessoa digita).
Efeitos
nenhum — não grava nada. A prévia vem do servidor, e não de uma terceira cópia da regra em TypeScript: o que a pessoa vê é exatamente o que o clique vai fazer.
curl -X GET 'https://api.pages.asender.net/v1/redirects' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/redirects
Criar link curto
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
204
apagado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/redirects/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/redirects/{id}
Atualizar um link curto
Onde é usada
edição.
Efeitos
escreve a linha.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos", nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/redirects/id_AQUI/cliques' \
-H 'Authorization: Bearer SEU_TOKEN'
Marca
GET/v1/configuracoes/marca
Obter a marca da conta
Onde é usada
tela de A/B do painel.
Efeitos
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/configuracoes/marca' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/configuracoes/marca
Definir a marca da conta
Onde é usada
clientes do contrato anterior à 037. O corpo NÃO tem tenant_id: a conta vem da sessão, e o campo desconhecido é recusado (400) — o silêncio faria quem tentou achar que funcionou.
Fallback página → domínio → padrão. Contrato único de consumo do construtor (autopreenchimento) e da geração por IA. Conta sem kit: origem: vazio, kit: null.
Onde é usada
autopreenchimento dos widgets do construtor e a geração por IA.
Parâmetros
nome
em
obrigatório
descrição
pagina
query
não
id público da página
dominio
query
não
host; ausente = o da página
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/configuracoes/marca/efetivo' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/configuracoes/marca/kits
Listar os kits de marca da conta
O padrão primeiro; cada kit traz quantos domínios/páginas o usam. Leitura no nível 0 (quem acessa o Pages).
Onde é usada
tela de marca e seletores de kit (domínios, editor).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/configuracoes/marca/kits' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/configuracoes/marca/kits
Criar (ou duplicar) um kit de marca
O primeiro kit da conta nasce padrão. duplicar_de copia os dados de outro kit da conta. Exige papel de editor.
Onde é usada
botões "novo kit" e "duplicar" da tela.
Respostas
código
significado
201
criado
400
corpo inválido (inclui campo desconhecido, ex. tenant_id)
401
Sem sessão / sem tenant na sessão.
403
sem o papel de editor no Pages
404
kit de origem (duplicar_de) não existe nesta conta
409
já existe um kit com esse nome
422
campo inválido (a mensagem nomeia o campo) ou limite de kits
curl -X GET 'https://api.pages.asender.net/v1/configuracoes/marca/kits/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/configuracoes/marca/kits/{id}
Salvar um kit de marca (nome e dados de uma vez)
PUT: o que não vier é apagado. A resposta traz os valores NORMALIZADOS (URL canônica de rede, E.164, CEP, documento só com dígitos/letras). Se o kit é o padrão, pages.marcas é reprojetada na mesma transação.
Onde é usada
botão salvar da tela de marca. duplicar_de aqui é recusado (400).
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
gravado
400
corpo inválido (campo desconhecido; duplicar_de só na criação)
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/midias' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/midias
Enviar uma imagem (multipart)
Envie o arquivo no campo arquivo de um multipart/form-data. O arquivo passa por antivírus antes de ser gravado: infectado = 422; antivírus configurado e fora do ar = 503 com Retry-After (fail-closed — tente de novo).
Onde é usada
botão de upload do editor.
Efeitos
escreve no disco e no banco. # A ordem é DISCO e depois BANCO Se o disco falhar, nada foi gravado e o erro é honesto. A ordem inversa deixaria uma linha apontando para um arquivo inexistente, e o visitante receberia erro numa página publicada. O caso duplicado não grava nada: a mídia já está lá, com os mesmos bytes.
Respostas
código
significado
200
já existia (deduplicada por conteúdo)
201
enviada
400
campo `arquivo` ausente
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
413
arquivo grande demais
422
arquivo recusado pelo antivírus
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
503
chave indisponível OU antivírus sem responder (reenvie; vem `Retry-After`)
Apagar uma imagem (soft delete — o arquivo fica; o suporte restaura)
Some da galeria e do endereço público, mas a linha e o ARQUIVO (original e variantes) permanecem no disco; o painel master restaura (ADR-0112). SÓ sessão.
Onde é usada
tela de domínios.
Efeitos
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
204
apagada (soft)
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Baixa a imagem da URL (com defesa contra SSRF). Desligado quando a importação por URL não está configurada (503).
Onde é usada
colar o endereço de uma imagem no editor.
Efeitos
uma requisição a servidor de terceiro; escreve no disco e no banco. # Esta é a rota mais perigosa do serviço Ela faz o SERVIDOR buscar um endereço que o USUÁRIO escolheu — a definição de SSRF. Sem as guardas, http://169.254.169.254/latest/meta-data/ devolveria as credenciais da instância, e http://redis-interno:6379/ alcançaria um serviço que nunca deveria ver tráfego de fora. Toda a defesa está em domain/midia: allowlist de esquema e porta, recusa de IP interno, conferência REPETIDA no momento de discar (contra DNS rebinding), zero redirecionamento e teto de tamanho durante a leitura.
Respostas
código
significado
200
já existia
201
importada
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
422
URL inválida / bloqueada
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
Rota PÚBLICA — quem pede é o navegador de um visitante numa página no ar, sem sessão. Devolve os bytes da imagem.
Onde é usada
a tag <img> de uma landing publicada.
Efeitos
abre e transmite o arquivo. # Sem sessão, e por isso com cuidado extra O id é o único parâmetro, e ele é resolvido no BANCO antes de tocar o disco: o caminho do arquivo nunca é montado com o que veio da URL. Path traversal não tem por onde entrar — e o armazenamento recusa id fora do formato de qualquer jeito, como segunda linha. # X-Content-Type-Options: nosniff é obrigatório aqui O tipo é o DETECTADO no upload, e a allowlist só tem imagem. Sem nosniff, o navegador pode reinterpretar o conteúdo e executar como outra coisa — e a allowlist de tipos perderia o sentido no último metro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
os bytes da imagem
404
mídia não encontrada
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/public/midias/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/public/midias/{id}/w/{largura}
Servir uma variante redimensionada da imagem (anônimo)
Rota PÚBLICA usada pelo srcset das páginas publicadas. Reduz a imagem para a largura pedida (nunca amplia; proporção e orientação EXIF preservadas) e a entrega em WebP quando o Accept anuncia image/webp (senão JPEG/PNG). A largura é FECHADA numa allowlist — qualquer outro valor é 404, sem tocar no banco nem no disco. Gerada uma vez e guardada; resposta com Cache-Control: public, max-age=31536000, immutable, Vary: Accept, X-Content-Type-Options: nosniff e ETag por (mídia, largura, formato). GIF, SVG, AVIF e imagens acima de 40 megapixels respondem o ORIGINAL (mesmos cabeçalhos da rota do original, inclusive a CSP do SVG).
Onde é usada
o srcset do <img> de uma landing publicada (renderizador do runtime, imagens_v2.go).
Efeitos
uma leitura no banco; leitura do disco; no primeiro pedido, um encode e uma escrita no disco. Largura fora da allowlist é 404 ANTES de qualquer acesso ao banco ou ao disco: é o que impede o moedor de CPU (ver midia.Larguras). Tudo o que dá errado DEPOIS disso (tipo sem variante, imagem acima do teto, falha do encoder) serve o ORIGINAL — a imagem da landing nunca quebra por causa da otimização.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
largura
path
sim
Accept
header
não
Com `image/webp`, a variante sai em WebP.
Respostas
código
significado
200
os bytes da variante (ou do original
404
largura fora da allowlist
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/public/midias/id_AQUI/w/largura_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
Páginas
GET/v1/paginas
Listar as páginas da conta
Onde é usada
tela de domínios.
Efeitos
uma leitura.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/paginas
Criar página
Cria uma página. Um dominio próprio exige posse PROVADA do host (422 se não verificado); um template com conteúdo privilegiado (bloco ou item html, HTML cru) exige nível pelo PEP (403). O template pode ser v1 ou v2 (ver TemplatePagina); recusa de v2 responde 422 com codigo e caminho (ErroConteudo).
Onde é usada
tela de domínios.
Efeitos
uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia banco.com.br e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.
Respostas
código
significado
201
criada
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
403
conteúdo privilegiado (html) sem nível
409
já existe página nesse endereço
422
título ausente / domínio não verificado / template fora do contrato (v2: com `codigo` e `caminho`)
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Arquivar a página (soft delete — despublica e esconde; o suporte restaura)
SÓ sessão (chave de API é recusada: chave nunca apaga). Despublica a página e a esconde da lista; as versões e o rascunho ficam guardados e o painel master restaura (volta despublicada). Nada é destruído (ADR-0112). Página inexistente, de outra conta ou já arquivada responde 404 (indistinguível).
Onde é usada
tela de domínios.
Efeitos
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
204
arquivada
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PATCH/v1/paginas/{id}
Editar slug, domínio ou título
Onde é usada
tela de páginas do painel (renomear) e configurações da página.
Efeitos
uma escrita. # Só o que veio é tocado nil é "não mandado" e &"" é "mandado vazio". Sem essa distinção, editar o título apagaria o endereço público da página — e ela sairia do ar sem ninguém ter pedido. # Validação IGUAL à da criação O slug e o título passam pelas mesmas regras do Criar. Um PATCH mais frouxo seria a porta dos fundos: bastaria criar válido e editar para inválido para pôr no ar um endereço que a criação recusa.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
atualizada
400
corpo inválido / nenhum campo
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Sessão ou API key com pages:write. Troca o endereço público da página: dominio_id é o id do domínio da conta (GET /v1/dominios); "" = host da plataforma. caminho é o slug de um segmento (^[a-z0-9]+(-[a-z0-9]+)*$); "" mantém o slug atual. Se a página já foi publicada alguma vez, o endereço antigo passa a responder 301 (308 em métodos que não GET/HEAD) para o novo. Repetir o mesmo corpo é idempotente (alterado:false). Página ou domínio inexistente, ou de outra conta, responde 404 (indistinguível).
Onde é usada
PUT /v1/paginas/{id}/dominio (sessão OU chave com pages:write).
Efeitos
uma transação.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
movida (ou já estava lá)
400
corpo inválido
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
`motivo`: `caminho_em_uso` (o endereço já é de outra página) ou `pagina_inicial` (a página é a home do domínio de origem)
422
`motivo`: `dominio_nao_verificado` ou `caminho_invalido`
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Tentar copiar de novo as mídias que ficaram na URL original
Roda a cópia de mídia sobre a ÚLTIMA versão da página (a mídia que já é da conta vira ja_na_conta, sem duplicar). Se alguma URL mudou, grava uma versão nova (não publica); senão responde 200 sem versão nova.
Onde é usada
botão "tentar copiar de novo" do relatório de importação.
Efeitos
downloads, galeria, no máximo uma versão nova.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
nada mudou
201
versão nova gravada
401
Sem sessão / sem tenant na sessão.
403
conteúdo privilegiado (html) sem nível
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
Publica a versão indicada; na sessão, sem corpo publica a última salva. API key (pages:publish): o corpo com versao é OBRIGATÓRIO (422 sem ele) — a chave publica uma versão que ela mesma identificou, nunca "a mais recente" às cegas (que poderia ser uma edição de pessoa ainda não revisada). Publicar é visível ao público e indexável.
Onde é usada
botão publicar.
Efeitos
move o ponteiro da página; o visitante passa a ver esta versão.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
publicada
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
422
essa versão não existe
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
O rascunho é UM por página, guardado SEM criar versão e SEM publicar. rascunho_em é a etiqueta de concorrência otimista; rascunho_por aparece quando foi uma chave de API quem gravou (apikey:<key_id>). Sem rascunho: 204.
Onde é usada
abertura do builder (recuperação).
Efeitos
nenhum.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
ok
204
sem rascunho pendente
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/rascunho' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/paginas/{id}/rascunho
Gravar o rascunho (sem criar versão e sem publicar)
Guarda o template como ele está, SEM as regras de publicação (só a forma: objeto JSON dentro do teto de 512 KiB) — elas rodam ao salvar versão e ao publicar. ?base=<rascunho_em> é a concorrência otimista: se o rascunho do servidor mudou desde o que você leu, nada é gravado e a resposta é 409 (codigo: RascunhoConflito) trazendo o rascunho do servidor — os dois lados ficam preservados. ?base= vazio significa "esperava não haver rascunho". Na sessão ?base= é opcional (clientes antigos gravam sem condição); na API key é OBRIGATÓRIO (428 sem ele): o agente nunca pisa no trabalho de uma pessoa.
Onde é usada
botão "Salvar rascunho" do editor-página (e o salvamento automático).
Efeitos
escreve rascunho; o formulário no ar NÃO muda. # Só a FORMA é conferida Rascunho existe para guardar trabalho pela metade — um campo sem rótulo, uma lista sem opção. Recusá-lo pelas regras de publicação faria a pessoa perder o que fez justamente quando ainda não terminou. A borda confere que o corpo é o do PUT (nenhum campo desconhecido, tipos certos, dentro do teto) e grava a forma DECODIFICADA re-serializada: chave estranha não chega ao banco. Toda a regra de domínio roda no PUBLICAR.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
base
query
não
o `rascunho_em` que você leu; vazio = esperava nenhum rascunho
Respostas
código
significado
200
gravado
400
corpo inválido ou `base` ilegível
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
o rascunho mudou desde a `base` (`codigo: RascunhoConflito`); o corpo traz `rascunho` do servidor
413
rascunho acima do teto
428
chave de API sem `?base=`
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Importar um arquivo exportado, copiando a mídia para a conta
Cria uma página (despublicada) a partir do template de um arquivo exportado (asender_pages_template: 2) e COPIA para a biblioteca da conta da sessão toda mídia referenciada (imagem de item, galeria, fundo de seção/coluna/página, ícone enviado, avatar/logo, imagem de compartilhamento): baixa de novo no servidor (mesmas guardas de SSRF da importação por URL, até 3 redirecionamentos, teto por arquivo e por importação, sniff de tipo, SVG sanitizado, antivírus) e troca cada URL pela nova. O que não puder ser copiado MANTÉM a URL original e aparece no relatório como mantido_original com o motivo. Referências presas à conta de origem (form_id que a conta não tem, *_rastreio, vínculos) são removidas, com aviso. A conta vem da sessão; o corpo não aceita tenant_id. Regras de POST /v1/paginas (domínio verificado, item privilegiado) valem aqui.
Onde é usada
"Importar template" da galeria de novo-template; reutiliza o mesmo serviço que o CLI de importação.
Efeitos
downloads de terceiros (com SSRF), arquivos e linhas na galeria, uma página e uma versão. NUNCA publica. # Falha de mídia não falha a importação Um ativo que não copia mantém a URL original e aparece no relatório; o painel oferece "tentar copiar de novo" (RecopiarMidias).
Respostas
código
significado
201
criada; o relatório diz o que foi copiado
400
corpo inválido (campo desconhecido
401
Sem sessão / sem tenant na sessão.
403
conteúdo privilegiado (html) sem nível
409
já existe página nesse endereço (checado antes de copiar)
422
título/template ausente, endereço inválido, SEO inválido ou template fora do contrato
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
500
não consegui preparar a importação (por exemplo, conferir os formulários)
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Ação em massa sobre páginas (publicar, despublicar, duplicar, mudar domínio, arquivar)
Executa UMA ação sobre até 100 páginas, cada uma de forma independente (sem tudo-ou-nada): o corpo da resposta traz o desfecho POR PÁGINA (ok, pulada ou erro, com motivo). A conta vem da sessão/chave; cada id é resolvido dentro dela e um id de outra conta é reportado como nao_encontrada, indistinguível de um id inexistente. simular: true devolve o que aconteceria, sem escrever nada. publicar publica a última versão SALVA e é idempotente (já no ar = ok com sem_mudanca). Conteúdo recusado pelo contrato de domínio é pulada/conteudo_invalido. Se houver rascunho de servidor mais novo que a última versão, a política rascunho decide: pular (padrão — pulada/rascunho_mais_novo) ou publicar_salva (publica a última versão salva; o rascunho NUNCA vira versão nem é descartado). despublicar tira do ar sem tocar em versões nem rascunho (idempotente). duplicar cria uma cópia despublicada da última versão salva (-copia, -copia-2…), com a mesma guarda de bloco privilegiado da criação. mudar_dominio delega ao serviço de mudança de domínio (mesmo contrato de PUT /v1/paginas/{id}/dominio, incluindo o 301 do endereço antigo); dominio_id é obrigatório ("" = host da plataforma) e caminho_por_pagina opcional (ausente = mantém o caminho atual). Cada página processada grava um log de auditoria (lote_id, conta, usuário, ação, desfecho). Limite de ritmo: 12 chamadas por minuto por conta (429). arquivar é o soft delete da página (despublica e esconde; versões e rascunho ficam; o master restaura — ADR-0112). SÓ sessão: chave de API é recusada (403). API key:publicar e despublicar exigem pages:publish; duplicar e mudar_dominio exigem pages:write (sem o escopo da ação, 403).
Onde é usada
barra de ações em massa da lista de páginas.
Entradas
corpo JSON (campos desconhecidos recusados). O tenant vem só do contexto autenticado; cada id é resolvido DENTRO do tenant (RLS + filtro), e id de outra conta é reportado como nao_encontrada — nunca confirma existência.
Saídas
200 com {lote_id, acao, simulado, total, ok, puladas, erros, resultados[]}; 400/422 no pedido malformado; 429 acima do ritmo.
Efeitos
escreve conforme a ação; um log auditoria por página com lote_id/trace_id, conta e usuário. Idempotente: publicar o que já está no ar e despublicar o que já está fora são ok com sem_mudanca. Sem tudo-ou-nada.
Respostas
código
significado
200
processado; veja `resultados` página a página
400
corpo inválido (campo desconhecido
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
422
ação inválida, 0 ou mais de 100 ids, política de rascunho inválida ou `dominio_id` ausente em mudar_dominio
GET/pixel.{versao:[0-9a-f]{16}}.jssem schema na spec
GET /pixel.{versao}.js — o mesmo script de `Script`, com `CacheScriptVersionado` quando a versão pedida é a servida e `CacheScriptVersaoAntiga` quando não é.
Onde é usada
o <script src> de toda página publicada (renderizador, PixelSrc).
Efeitos
escreve o script. Versão antiga NÃO vira 404 nem redirect: um 404 desligaria a analytics da página em cache até ela expirar, e um redirect custaria uma ida à rede a mais no caminho de quem só queria o script.
Exemplo
curl -X GET 'https://api.pages.asender.net/pixel.versao:[0-9a-f]{16_AQUI}.js' \
-H 'Authorization: Bearer SEU_TOKEN'
Pixels
GET/v1/pixels
Listar pixels
Onde é usada
a tela de leads da ferramenta de Páginas/Formulários.
Efeitos
uma consulta; uma abertura de AES-GCM por linha; um registro de acesso a PII no log. Devolve {"envios": [...], "proximo_cursor": ""} — cursor vazio significa que acabou. A alternativa (omitir o campo no fim) obrigaria o cliente a distinguir ausente de vazio, e é o tipo de sutileza que vira laço infinito na tela.
Respostas
código
significado
200
ok
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/pixels' \
-H 'Authorization: Bearer SEU_TOKEN'
curl -X GET 'https://api.pages.asender.net/v1/pixels/id_AQUI' \
-H 'Authorization: Bearer SEU_TOKEN'
PATCH/v1/pixels/{id}
Editar um pixel
Onde é usada
tela de detalhe.
Efeitos
uma escrita. PATCH parcial de verdade: campo ausente não é tocado. Sem isso, editar só o nome DESATIVARIA o pixel — e a coleta pararia sem ninguém ter pedido.
304 quando o `If-None-Match` bate; senão o bundle, com cache de uma hora e `X-Content-Type-Options: nosniff`.
Onde é usada
rota /chat.js do runtime.
Entradas
w; r.
Efeitos
nenhum — é leitura de bytes já em memória. Não há CORS aqui, e é de propósito. Um <script src> não precisa de CORS para executar, e liberar Access-Control-Allow-Origin: * neste arquivo só abriria a leitura do conteúdo por fetch de qualquer origem sem ganho nenhum. Quem precisa de CORS é a API do chat, e lá a origem é conferida contra a chave de site — não liberada em *. ifNoneMatchCasa (comparação FRACA, RFC 9110 §13.1.2), não ==: a borda comprime este script on-the-fly e rebaixa o ETag forte para W/"…" na resposta — o navegador devolve esse W/ na revisita, e == contra o forte nunca batia (achado em embed.js, forms-rapidos 2026-10-01; mesma causa aqui, mesmo remédio — ver etag_match.go).
Exemplo
curl -X GET 'https://api.pages.asender.net/' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/sem schema na spec
resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos.
Onde é usada
POST no caminho da própria página.
Efeitos
os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro form_id. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.
Resumo da ferramenta para o hub e a home (contrato comum de resumo).
Visitas (sessões), visualizações, conversões de formulário e taxa de conversão — com a janela anterior —, páginas publicadas (no ar agora) e a série de visitas por dia. Visitas e conversões vêm do runtime; com ele fora do ar, esses indicadores e a série SOMEM (nunca viram zero) e o cartão segue com as páginas publicadas. Tenant e usuário vêm da credencial verificada, nunca da query. Conta alheia → 404; membro sem acesso ao produto → 403.
Onde é usada
tela de A/B do painel.
Efeitos
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
periodo
query
não
janela dos indicadores — os N últimos dias fechados em hoje (UTC). Padrão 30d.
Respostas
código
significado
200
o resumo da ferramenta `pages`.
400
Parâmetro de query fora do conjunto aceito.
401
Sem sessão / sem tenant na sessão.
403
membro da conta sem acesso a esta ferramenta.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/resumo' \
-H 'Authorization: Bearer SEU_TOKEN'
robots.txt
GET/robots.txtsem schema na spec
libera a indexação e aponta o sitemap do MESMO host — ou fecha o host inteiro (`Disallow: /`) quando ele não deve ir à busca (ver `fechado`).
Onde é usada
rota pública.
Efeitos
uma leitura no asender_pages_api (quando há fonte). # Por que liberar é o padrão A página existe para ser achada: é landing page de campanha, e quem a publica quer tráfego. Fechar o host é DECISÃO — do cliente (noindex do site), do estado (nada publicado e indexável) ou da política (PAGES_INDEXACAO) —, nunca o default da plataforma. Falha ao consultar o asender_pages_api LIBERA: um blip não pode tirar o site de um cliente da busca (o cache curto de 5 min do documento fechado vale pelo mesmo motivo). O noindex de UMA página não vira Disallow: o buscador precisa conseguir ler a página para ver o noindex dela — bloqueada no robots, ela pode continuar no índice pelos links que apontam para ela. O sitemap é apontado com o HOST da requisição, e não com um domínio configurado: cada cliente tem o dele, e um endereço fixo aqui mandaria o buscador de todo mundo para o sitemap de um só.
Exemplo
curl -X GET 'https://api.pages.asender.net/robots.txt' \
-H 'Authorization: Bearer SEU_TOKEN'
Roteamento
GET/v1/paginas/{id}/cliques
Cliques por destino de roteamento
Onde é usada
relatório da tela de links curtos.
Efeitos
uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos", nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
dias
query
não
janela em dias
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/cliques' \
-H 'Authorization: Bearer SEU_TOKEN'
DELETE/v1/paginas/{id}/roteamento
Apagar a regra de roteamento
Onde é usada
tela de domínios.
Efeitos
uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
204
apagada
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/roteamento' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/paginas/{id}/roteamento
Salvar a regra de roteamento
Exclusivo com o experimento A/B (ADR-0014). Máximo de 20 destinos.
Onde é usada
botão salvar do editor.
Efeitos
cria uma versão.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
salva
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Desde a 041 só noindex é do site. Os demais campos de SEOSite são o ESPELHO, só leitura, do kit de marca efetivo do host, e seo.kit identifica o kit ({id, nome, origem}).
Onde é usada
tela "SEO do site".
Efeitos
uma leitura. Host de outra conta (ou não verificado) → 404.
Parâmetros
nome
em
obrigatório
descrição
dominio
query
não
host do domínio verificado da conta; ausente/vazio = o host da plataforma
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
host não é um domínio verificado desta conta
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/configuracoes/seo' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/configuracoes/seo
Definir a indexação do site (um host da conta)
Grava só noindex, que muda robots.txt, sitemap.xml e o robots do <head> de todas as páginas do host. Nome do site, imagem, favicon e organização moram no kit de marca (041): mandá-los preenchidos aqui dá 422.
Onde é usada
tela "SEO do site".
Efeitos
uma escrita; muda robots, sitemap e <head> de todo o host.
Parâmetros
nome
em
obrigatório
descrição
dominio
query
não
Respostas
código
significado
200
gravado
400
corpo inválido (inclui campo desconhecido, ex. tenant_id)
401
Sem sessão / sem tenant na sessão.
404
host não é um domínio verificado desta conta
422
campo que mora no kit de marca veio preenchido — a mensagem diz onde editá-lo
Página nunca configurada responde 200 com os campos vazios — o runtime usa os padrões (título ← nome da página, descrição ← primeiro texto, og:image ← primeira imagem ou a do site).
Onde é usada
tela "SEO e compartilhamento" do painel.
Efeitos
uma leitura. Página nunca configurada responde 200 com os padrões.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
não existe — ou é de outra conta (mesma resposta)
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/seo' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/paginas/{id}/seo
Definir o SEO e o compartilhamento de uma página
Todos os campos de uma vez; vazio volta ao padrão. Vale na próxima resolução do runtime, sem publicar versão.
Onde é usada
tela "SEO e compartilhamento".
Efeitos
uma escrita; vale na próxima resolução do runtime (sem publicar).
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
gravado
400
corpo inválido (inclui campo desconhecido, ex. tenant_id)
401
Sem sessão / sem tenant na sessão.
403
A chave não tem o escopo exigido pela rota (ou a rota não aceita chave). Ex.: `{"erro":"a chave não tem o escopo forms:write"}`.
404
não existe — ou é de outra conta (mesma resposta)
422
campo inválido — a mensagem nomeia o campo (tamanho, URL fora de http/https, imagem sem https, dimensões)
429
Escritas demais por esta chave (teto por minuto: 60 nas escritas de `pages:write`, 20 nas de `pages:publish`). Header `Retry-After: 60`.
503
Não foi possível validar a chave agora (core fora do ar). Tente de novo.
Gravar um lote de jornada (anônimo, credencial de pixel)
Chamado pelo pixel no navegador do visitante. A credencial (token assinado do pixel) vai no corpo; o relógio é o do SERVIDOR. Responde 204 quase sempre; 403 para credencial/origem inválida, 429 para excesso. Servido no runtime.
Onde é usada
rota pública, chamada pelo pixel a cada poucos segundos de uma sessão gravada.
Efeitos
publica no broker; NUNCA escreve no banco. Responde 204 quase sempre, como o /collect: credencial inválida é 403 (o HTML está velho), e o resto é aproveitado. O pixel roda no browser de terceiros, e transformar um lote parcialmente ruim em erro visível não conserta nada.
curl -X GET 'https://api.pages.asender.net/v1/replay/sessions' \
-H 'Authorization: Bearer SEU_TOKEN'
GET/v1/replay/sessions/{id}/events
Eventos de uma jornada gravada
Onde é usada
player da tela de replay.
Efeitos
uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id é opaco e gerado no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo de existência de sessão alheia.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
Respostas
código
significado
200
ok
400
sessão inválida
401
Sem sessão / credencial ausente ou inválida.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/replay/sessions/id_AQUI/events' \
-H 'Authorization: Bearer SEU_TOKEN'
sitemap.xml
GET/sitemap.xmlsem schema na spec
lista as páginas PUBLICADAS e INDEXÁVEIS daquele host (sem noindex, sem canônica externa; a home do domínio pela raiz). Host fechado pela política ou site com noindex global → documento vazio.
Onde é usada
rota pública.
Efeitos
uma leitura no asender_pages_api. # Host sem página responde XML VAZIO, e não 404 Um 404 no sitemap faz o buscador registrar erro e tentar de novo; um documento vazio diz "não há nada para indexar aqui", que é a verdade. E o host que ainda não tem página publicada é o caso mais comum logo depois de alguém cadastrar um domínio. # Rascunho não entra A consulta do asender_pages_api filtra por versão publicada. Listar rascunho entregaria ao buscador o endereço de uma página que o cliente ainda não quis mostrar — e o buscador não esquece.
Exemplo
curl -X GET 'https://api.pages.asender.net/sitemap.xml' \
-H 'Authorization: Bearer SEU_TOKEN'
Testes A/B
POST/v1/paginas/{id}/ab/desvincular
Desvincular a PRÓPRIA página do teste em que ela está (lista de páginas)
Onde é usada
ação "Desvincular do teste" da lista de páginas.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
desvinculada
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Pausar o teste (rodando → pausado; todo visitante volta à página publicada)
Onde é usada
botão "Pausar" da tela de A/B.
Efeitos
uma escrita; todo visitante volta a ver a página publicada. A amostra fica congelada (o runtime só conta quem viu uma variante) e retomar continua a mesma medição: iniciado_em não muda.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
pausado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Promover uma variante (e opcionalmente iniciar o teste)
Com iniciar: true, inicia o teste (exige pesos válidos). Sem ele, ENCERRA com a vencedora e, se ela é uma página-variante, ela ASSUME A URL do teste (a principal antiga segue como página normal), na mesma transação (ADR-0095).
Onde é usada
botão "promover vencedora" e botão "iniciar teste" da tela de A/B.
Efeitos
uma escrita; promover também põe a página vencedora na URL. # Duas operações na mesma rota, e por quê O lynz expõe POST /v1/pages/{id}/ab/promote, sem rota de iniciar — o teste começa ao ser promovido a "rodando". Manter uma rota só preserva a superfície do lynz, e o corpo diz qual das duas transições é pedida. # Iniciar EXIGE o experimento válido É o único ponto onde o rascunho inconsistente é barrado, e é o ponto certo: pesos que não somam 100 deixariam uma faixa do tráfego sem dono, e Escolher devolveria a última variante para essa fatia — um viés silencioso que o número final não denuncia.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Retomar um teste pausado (mesma medição; o início não muda)
Onde é usada
botão "Retomar" da tela de A/B.
Efeitos
uma escrita; a distribuição volta para os visitantes novos, e quem já tinha braço fixado continua nele. Valida antes, como ao iniciar: com o teste pausado os braços puderam mudar.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
rodando
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
o teste não está pausado, ou conflito (outro rodando / roteamento)
uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/experimento' \
-H 'Authorization: Bearer SEU_TOKEN'
PUT/v1/paginas/{id}/experimento/auto-stop
Definir o critério de decisão e o autobalanceamento (ADR-0093)
Campos ausentes mantêm o valor gravado. Aceito com o teste rodando — nada aqui reatribui visitante já contado. Campo desconhecido (inclusive o antigo margem_para_encerrar, removido em 2026-10-07) é recusado com 400.
Onde é usada
painel "Critério e autobalanceamento" da tela de A/B.
Efeitos
uma escrita. Aceito com o teste RODANDO, ao contrário das variantes: nenhum destes campos reatribui visitante já contado (a atribuição é fixada por visitante) — mudam o rótulo da tela e a fatia do visitante NOVO.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
definido
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Definir a divisão de tráfego inteira, atomicamente (ADR-0093)
Um peso por variante existente, inteiros 0–100 somando exatamente 100 e ao menos duas com peso ≥ 1. Aceito com o teste rodando ou pausado (a atribuição é fixada por visitante: o peso novo vale para visitantes novos); 409 encerrado.
Onde é usada
a barra de divisão da tela de A/B ("Salvar divisão").
Efeitos
uma escrita. # Atômico, e por quê O defeito que o dono encontrou ("Soma dos pesos: 150%") nasceu de editar peso por peso: entre duas edições a soma passa por valores inválidos, e a tela não tinha como chegar a 100 sem ida e volta por variante. Aqui o conjunto chega inteiro e o domínio valida o resultado inteiro: soma exatamente 100, cada peso em 0–100 e ao menos dois braços com peso ≥ 1 (ADR-0093). # Aceito com o teste RODANDO ou PAUSADO A atribuição é fixada por visitante no runtime (cookie por experimento): o peso novo só decide quem chega DEPOIS. Encerrado recusa (409) — o tráfego já é todo da vencedora.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
divisão gravada
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
experimento encerrado
422
soma ≠ 100, variante faltando/a mais, peso fora de 0–100 ou menos de duas ativas
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/id_AQUI/variantes' \
-H 'Authorization: Bearer SEU_TOKEN'
POST/v1/paginas/{id}/variantes
Acrescentar uma PÁGINA ao teste — duplicar ou vincular (ADR-0095)
{id} é a página da URL do teste; sem teste aberto, ele nasce aqui com {id} como principal. "duplicar" cria uma página normal nova (cópia da última versão de pagina_id, ou da principal), publicada, com slug <url>-ab-<braço> e sem rota própria. "vincular" liga uma página existente do MESMO domínio que não esteja em outro teste aberto. A divisão volta a ser igual. 409 com o teste rodando (pause antes).
Onde é usada
botões "Criar variante" e "Vincular página existente" da tela de A/B.
Efeitos
escritas atômicas; a divisão é refeita em partes iguais. O braço é uma PÁGINA normal (aparece na lista, abre no editor, tem SEO e versões próprios); o teste só guarda a relação e o peso.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
Respostas
código
significado
201
o experimento atualizado
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
409
teste rodando / id de braço já usado / página já em outro teste
422
modo inválido / página de outro domínio / id fora do formato
Desvincular a página do braço (ela continua existindo)
Os pesos que ficam renormalizam; a amostra do braço fica no histórico. Saindo a principal, o braço de maior peso assume a URL; sobrando uma página, o teste encerra e ela fica na URL. Aceito com o teste rodando.
Onde é usada
tela de A/B.
Efeitos
escritas atômicas (ver repo.Desvincular): os pesos que ficam renormalizam; sobrando uma página, o teste encerra e ela fica na URL. Aceito com o teste rodando.
Parâmetros
nome
em
obrigatório
descrição
id
path
sim
id da página; o acesso é conferido contra a sessão (deny-default).
varianteID
path
sim
Respostas
código
significado
200
desvinculada
401
Sem sessão / sem tenant na sessão.
404
Recurso inexistente ou fora do escopo da sessão (não revela existência em outra conta).
Páginas da conta em teste A/B não encerrado (selo da lista)
Onde é usada
lista de páginas do painel.
Efeitos
uma leitura sob o tenant da sessão.
Respostas
código
significado
200
ok
401
Sem sessão / sem tenant na sessão.
Exemplo
curl -X GET 'https://api.pages.asender.net/v1/paginas/ab-vinculos' \
-H 'Authorization: Bearer SEU_TOKEN'
version
GET/versionsem schema na spec
nome do serviço, versão e o commit que gerou o binário.
Onde é usada
rota pública, usada por ops e por quem investiga incidente.
Efeitos
escreve JSON na resposta. # Por que ela é PÚBLICA, e o que ela não conta A primeira pergunta de todo incidente é "que versão está no ar?" — e ela tem de ser respondível sem credencial, porque quem investiga muitas vezes ainda não tem uma. O que sai é o que já está no docker inspect de quem tem acesso ao host: nome, versão e commit. Nunca configuração, nunca dependência, nunca endereço interno — isso é topologia, e topologia é do console de plataforma. A versão vem de LDFLAGS no build, e o default é dev: um binário sem carimbo diz dev em vez de mentir uma versão que ninguém emitiu.
Exemplo
curl -X GET 'https://api.pages.asender.net/version' \
-H 'Authorization: Bearer SEU_TOKEN'