Skip to content

API de Formulários

Formulários de pesquisa criados no painel admin e respondidos por um link próprio (ADR-0142). A página do link é servida pela própria API (ADR-0143): o link já funciona, sem depender de tela no app.


📋 Endpoints

Método Endpoint Descrição Auth
GET /f/{slug} Página pronta do formulário (HTML) ❌ Não
GET /forms/{slug} Ler a definição (JSON, para telas próprias) ❌ Não*
POST /forms/{slug}/responses Responder ❌ Não*
POST /forms/{slug}/upload Enviar anexo de um campo file ❌ Não*
GET /forms/purpose/{purpose} Questionário do app, por propósito 🔑 Cliente
GET /form-assets/{file_id} Imagem de fundo do formulário ❌ Não
GET/POST /admin/forms Listar / criar ✅ Admin
PUT/DELETE /admin/forms/{form_id} Editar (publicar, encerrar) / excluir ✅ Admin
POST /admin/forms/{form_id}/duplicate Duplicar ✅ Admin
GET/DELETE /admin/forms/{form_id}/responses Listar / excluir respostas ✅ Admin
GET /admin/forms/{form_id}/summary Resumo por pergunta ✅ Admin
GET /admin/forms/{form_id}/responses/export Exportar xlsx/csv/json ✅ Admin (Bearer ou ?token=)
GET /admin/forms/{form_id}/responses/files/{file_id} Baixar anexo de uma resposta ✅ Admin (Bearer ou ?token=)
POST/DELETE /admin/forms/{form_id}/background-head Trocar / remover a imagem do topo ✅ Admin
POST/DELETE /admin/forms/{form_id}/background-page Trocar / remover o fundo da página ✅ Admin

* Formulário com access: "login" exige Authorization: Bearer <jwt> — para ler, responder e enviar anexo. Nos demais, o token é opcional (no OpenAPI: security: [{}, {OAuth2}]).

⚠️ Mudança em 21/09: GET /forms/{slug} passou a exigir token em access: "login". Antes entregava o questionário inteiro a quem tivesse o link, enquanto a página /f/{slug} já se recusava a desenhá-lo (ADR-0155).


🧩 A pergunta (fields[])

Campo Tipo Descrição
id string Identificador estável (^[a-z0-9_]{1,50}$). Omitido na criação = gerado. Ao editar, a pergunta que já existe precisa voltar com o mesmo id; sem ele vira pergunta nova e as respostas antigas ficam órfãs
label string Enunciado (1–300)
type enum short_text, long_text, email, number, date, single_choice, multiple_choice, dropdown, scale, file
required bool Obrigatória (default false)
help_text string Texto de apoio (≤ 500)
options string[] Só nos tipos de escolha: 1–50 opções, únicas, ≤ 200 chars
min_value / max_value número number: faixa. scale: primeiro e último ponto (default 1 e 5; começa em 0 ou 1, até 11 pontos)
max_length int Texto: short_text ≤ 300, long_text ≤ 5000
max_selections int multiple_choice: máximo de opções marcadas
accepted_types string[] Só em file. Extensões aceitas; vazio = todas. Extensão fora da lista da API = 422 ao salvar a pergunta
max_files int Só em file: quantos arquivos o respondente envia (1–10, default 1)
max_file_size_mb int Só em file: teto por arquivo (1–25, default 10)

Um formulário tem até 100 perguntas. Publicar (status: "published") exige ao menos uma.


📎 Anexos (campo file, ADR-0153)

Duas etapas, porque arquivo não cabe no JSON da resposta:

  1. POST /api/v1/forms/{slug}/upload — multipart/form-data com file e field_id. Devolve {file_id, filename, content_type, size}.
  2. POST /api/v1/forms/{slug}/responses — o valor da pergunta é a lista dessas referências: {"answers": {"comprovantes": [{"file_id": "...", "filename": "..."}]}}.

Formatos aceitos: jpg/jpeg, png, bmp, webp, pdf, csv, xls, xlsx.

⚠️ O formato é conferido pelo conteúdo, não pela extensão. Um .pdf renomeado para .png é recusado com 415; um .jar com nome de .xlsx também. E o nome gravado ganha a extensão do conteúdo real — quem envia foto.png com bytes de PDF recebe de volta filename: "foto.pdf". Mostre o filename que a API devolveu, não o que o usuário escolheu.

⚠️ O anexo expira em 24 h se nenhuma resposta o citar. Quem sobe e abandona não deixa lixo. Citado na resposta, o prazo some e o arquivo fica. Se o usuário demorar mais de um dia entre subir e enviar, a resposta é recusada (422, "envie o anexo novamente") — trate esse erro reabrindo o seletor de arquivo, não como falha genérica.

Erros: 413 acima do teto do campo · 415 formato recusado · 429 limite por origem (20 respostas e 40 uploads por hora, por IP) · 422 referência de outro formulário ou expirada · 409 formulário fora da janela.

O admin baixa em GET /admin/forms/{form_id}/responses/files/{file_id}; no export, a célula sai como nome (url).

Desde 21/09 a rota devolve o tipo real do arquivo (derivado da extensão gravada, que é a do conteúdo verificado) e Content-Disposition: inline para imagem e PDF — abrem no navegador em vez de baixar. Planilha e CSV seguem attachment, porque o navegador não os exibe. Acompanha X-Content-Type-Options: nosniff.


🖼️ Imagens de fundo (ADR-0152)

background_head (topo) e background_page (fundo da página). Troca por POST /admin/forms/{form_id}/background-head (ou -page), multipart com file; remove por DELETE no mesmo caminho.

  • Só imagem: jpg, jpeg, png, webp (PDF é recusado aqui, com 415)
  • Teto de 2 MB → 413
  • O formulário devolve background_head_url/background_page_url prontas para usar; os campos background_head/_page guardam só o file_id
  • ⚠️ Corrigido em 21/09: entre 20 e 21/09 a URL saía sem o prefixo /api/v1 e respondia 404. Quem montou o endereço por conta própria pode voltar a usar o campo
  • A URL aceita ?w=/?q=/?fmt= (ADR-0109) e é pública — <img src> não manda header Authorization

GET https://fdplay-api.infraifd.com/f/{slug} devolve a página pronta, responsiva, com os campos renderizados. É o link que o painel mostra em public_url e que se manda por WhatsApp, e-mail ou QR Code.

  • Layout (ADR-0159): a imagem do topo é um bloco próprio (.capa, 162px), sem texto por cima; título e descrição vêm num cartão branco logo abaixo. A arte do banner já costuma ter texto, e sobrepor deixava as duas camadas competindo. Sem background_head, a página fica idêntica à de antes
  • O rodapé tem duas ações: "Limpar formulário" à esquerda (com confirmação) e "Enviar respostas" à direita. No celular, "Enviar" fica acima
  • Envia pela mesma rota de API (POST /api/v1/forms/{slug}/responses) — quem valida é a API
  • Encerrado, fora do prazo ou rascunho: página de aviso (rascunho e link errado = 404)
  • access: "login": a página avisa para responder pelo app, porque não faz login
  • Todo texto do admin sai escapado; a página não entra em buscadores (noindex)

Para uma tela própria (app ou site), use o JSON de GET /api/v1/forms/{slug}, descrito abaixo.


📱 Questionário do app, por propósito (ADR-0154)

O app precisa exibir um questionário antes de cancelar a assinatura ou excluir a conta, sem ter o slug no código.

GET /api/v1/forms/purpose/{purpose} — app_cancel_subscription ou app_delete_account. Exige token de cliente (não precisa ser admin).

  • 200 com a mesma definição pública de GET /forms/{slug}, agora incluindo purpose — confira que veio o formulário que você pediu
  • 204 sem corpo quando não há questionário publicado — situação normal: siga direto para o cancelamento. Não trate como erro
  • 422 se o purpose for standalone (esse se responde pelo link)

⚠️ Questionário de app não é legível pelo link. GET /forms/{slug} e a página /f/{slug} respondem 404 para ele. access: "login" impediria responder, mas não ler — e a leitura anônima entregaria as perguntas do questionário de saída a qualquer um com o slug. Responder e subir anexo continuam por slug, que é o que a rota por propósito devolve.

No painel, purpose é campo do formulário e é ortogonal ao status: pode haver rascunho e encerrado do mesmo propósito, mas só um publicado — o segundo recebe 409 nomeando o atual. purpose de app exige access: "login".

Respondido o questionário, mande o form_response_id no corpo do cancelamento ou da exclusão (SubscriptionCancelRequest / DeleteAccountRequest): é o que liga a saída ao que a pessoa respondeu. Ver api-registros-de-saida.md.

🐛 20–21/09: a rota recusava o token de cliente com 401. Não era o token — era o RBAC por nome de handler (ADR-061): a rota nasceu fora do BYPASS_ROUTES e cliente, que não tem perfil, levava "You do not have permission for view". O app tratava o 401 como sessão morta e deslogava. Corrigido (ADR-0161), com gate que deriva de app.routes toda rota self-service de auth manual e roda o AuthControl real.

Como gravar a resposta — o desenho coerente:

O quê Onde Para quê
Resposta completa (escala, múltipla escolha, anexo) POST /forms/{slug}/responses resumo por pergunta em /admin/forms/{id}/summary
form_response_id devolvido corpo do DELETE /customers/me/subscription ou DELETE /customers/me liga a saída ao questionário
reasons (dicionário curto) mesmo corpo relatório de churn by_reason (ADR-0147)

Mande os dois — reasons é o resumo que o churn lê; form_response_id é o registro inteiro. Só reasons deixa /admin/forms/{id}/summary cego para essas respostas.

  • one_response_per_user é proibido em propósito de app (422 ao criar ou editar): cancelar e excluir são eventos repetíveis — quem cancela, volta e cancela de novo responde de novo. Sem isso a segunda vez daria 409 e o app abortaria a saída.
  • form_response_id é conferido antes de qualquer efeito: precisa existir, ser do próprio titular (respondent_id) e do questionário do fluxo — a resposta do cancelamento não serve para a exclusão. Fora disso, 422 em form_response_id.

Rascunho (draft) responde 404, como se não existisse.

{
  "slug": "pesquisa-de-satisfacao-a1b2c3",
  "title": "Pesquisa de satisfação",
  "description": "Leva 2 minutos.",
  "access": "public",
  "one_response_per_user": false,
  "opens_at": null,
  "closes_at": null,
  "success_message": "Obrigado!",
  "fields": [
    {"id": "nome", "label": "Nome", "type": "short_text", "required": true,
     "help_text": null, "options": [], "min_value": null, "max_value": null,
     "max_length": null, "max_selections": null},
    {"id": "nota", "label": "Nota", "type": "scale", "required": false,
     "options": [], "min_value": 1, "max_value": 10}
  ],
  "accepting_responses": true,
  "closed_reason": null
}

closed_reason: closed (encerrado), not_open_yet (antes de opens_at) ou expired (depois de closes_at). Com accepting_responses: false, mostre a definição em modo leitura.

2. POST /api/v1/forms/{slug}/responses — Responder

{"answers": {"nome": "Maria Souza", "nota": 9, "assiste": ["Cultos", "Estudos"]}}

Formato de cada valor: texto, e-mail e data (AAAA-MM-DD) são string; número e escala são número; escolha única é a opção; múltipla escolha é lista de opções. Pergunta opcional pode vir em branco (null, "" ou []) ou ser omitida — não é gravada.

201:

{"_id": "6aac...", "register_date": "2026-09-17T18:34:28.462000Z", "success_message": "Obrigado!"}

Erro Quando
401 access: "login" e requisição sem token válido
404 Slug inexistente ou formulário em rascunho
409 field: "form" Encerrado ou fora da janela
409 field: "respondent" one_response_per_user e o usuário já respondeu
422 field: "answers.<id>" Pergunta inexistente, obrigatória em branco, opção fora da lista, tipo ou limite errado

3. POST /api/v1/admin/forms — Criar

Corpo = a definição (exemplo no OpenAPI). slug omitido é gerado do título com sufixo aleatório. Default status: "draft". A resposta traz o formulário no envelope padrão de listagem (docs), com dois campos calculados:

  • public_url: link pronto (FORMS_PUBLIC_BASE_URL + slug); null se o ambiente não tiver a URL base configurada
  • responses_count: respostas recebidas

4. PUT /api/v1/admin/forms/{form_id} — Editar, publicar, encerrar

Atualização parcial: só os campos enviados mudam. status publica (published) e encerra (closed). Mudar fields incrementa version.

  • 409 field: "slug" — o link não muda depois da primeira resposta
  • 422 — resultado incoerente (publicar sem pergunta, one_response_per_user sem login, opens_at depois de closes_at, pergunta incoerente com o tipo)

5. Listar, excluir e resumir respostas

GET /admin/forms/{form_id}/responses?start=&end=&qty_docs_page=50&current_page=0, da mais recente para a mais antiga. Cada resposta:

{
  "_id": "6aac...",
  "form_id": "6aab...",
  "form_version": 2,
  "respondent_id": null,
  "answers": [
    {"field_id": "nome", "label": "Nome", "type": "short_text", "value": "Maria Souza"},
    {"field_id": "nota", "label": "Nota", "type": "scale", "value": 9}
  ],
  "register_date": "2026-09-17T18:34:29.945000Z"
}

DELETE no mesmo caminho, corpo [{"_id": "..."}]; _id de outro formulário → 404.

GET /admin/forms/{form_id}/summary (aceita start/end):

{
  "total_responses": 3,
  "fields": [
    {"field_id": "nota", "label": "Nota", "type": "scale", "answered": 2,
     "average": 9.0, "min": 8.0, "max": 10.0,
     "distribution": [{"value": 1, "count": 0}, "…", {"value": 10, "count": 1}]},
    {"field_id": "assiste", "label": "O que assiste?", "type": "multiple_choice", "answered": 2,
     "options": [{"option": "Cultos", "count": 2}], "other": [{"option": "Estudos", "count": 1}]}
  ]
}

distribution traz todos os pontos da escala, inclusive os de contagem zero — serve direto para o gráfico. other são opções respondidas que não existem mais na definição. O resumo usa as perguntas atuais: pergunta removida não aparece. Campo que não se aplica ao tipo sai da resposta — pergunta de texto não traz average, escolha não traz distribution.

Esquemas no Swagger: FormPublicResponse, FormResponseCreated, FormSummary, FormField (com o enum dos tipos) e FormCreate/FormUpdate nos corpos do admin.

6. Exportar

GET /admin/forms/{form_id}/responses/export?format=xlsx|csv|json&start=&end=&token=<jwt>. Uma linha por resposta; colunas: Recebida em, uma por pergunta atual e, no fim, as perguntas que saíram da definição. Múltipla escolha vira texto separado por ;. Até 50.000 linhas (ADR-059).


⚙️ Detalhes

  • Link público: public_url sai de FORMS_PUBLIC_BASE_URL quando configurada; sem ela, do DOMAIN do ambiente + /f — a página que a própria API serve. Para trocar pela tela do app, basta apontar [<ambiente>.forms] public_base_url para ele: é config, não deploy.
  • Permissões: root acessa direto. Admin com perfil precisa dos handlers admin_forms_get, admin_forms_post, admin_forms_put, admin_forms_delete, admin_form_duplicate_post, admin_form_responses_get, admin_form_responses_delete e admin_form_summary_get. O export autentica no próprio handler e exige só user_type=admin.
  • Exclusão do formulário: só sem respostas (409); com respostas, encerre.
  • Erros: formato único da API (error.code, error.message, error.field, error.details) — ver Visão geral. Na tela pública, use error.field (answers.<id>) para destacar a pergunta.

Abuso

A API não limita requisições por IP. A definição barra conteúdo fora do formato, não volume: contra spam, a proteção é na borda (proxy/Cloudflare) ou captcha.


📱 Implementação no Flutter

Renderizar a tela pública (/f/{slug} no app do cliente):

final res = await http.get(Uri.parse('$baseUrl/api/v1/forms/$slug'));
if (res.statusCode == 404) return const FormularioNaoEncontrado();

final form = jsonDecode(res.body);
final aceita = form['accepting_responses'] as bool;

// Um widget por tipo de pergunta
Widget campoPara(Map<String, dynamic> f) => switch (f['type'] as String) {
      'short_text' || 'email' => TextFormField(decoration: InputDecoration(labelText: f['label'])),
      'long_text' => TextFormField(maxLines: 5, maxLength: f['max_length'] ?? 5000),
      'number' => TextFormField(keyboardType: TextInputType.number),
      'date' => DatePickerField(label: f['label']), // enviar como 'AAAA-MM-DD'
      'single_choice' => RadioGroup(opcoes: List<String>.from(f['options'])),
      'dropdown' => DropdownButtonFormField(items: List<String>.from(f['options'])),
      'multiple_choice' => CheckboxGroup(
          opcoes: List<String>.from(f['options']), maximo: f['max_selections']),
      'scale' => SliderScale(min: f['min_value'] ?? 1, max: f['max_value'] ?? 5),
      _ => const SizedBox.shrink(),
    };

Enviar a resposta — as chaves de answers são os id das perguntas:

final res = await http.post(
  Uri.parse('$baseUrl/api/v1/forms/$slug/responses'),
  headers: {
    'Content-Type': 'application/json',
    // Só quando form['access'] == 'login':
    if (precisaLogin) 'Authorization': 'Bearer $accessToken',
  },
  body: jsonEncode({
    'answers': {
      'nome': 'Maria Souza',       // texto
      'nota': 9,                   // número / escala
      'quando': '2026-01-31',      // data
      'assiste': ['Cultos'],       // múltipla escolha
    },
  }),
);

final corpo = jsonDecode(res.body);
switch (res.statusCode) {
  case 201:
    mostrarSucesso(corpo['success_message'] ?? 'Resposta enviada!');
  case 409:
    // field == 'form' → encerrado/fora do prazo; field == 'respondent' → já respondeu
    mostrarAviso(corpo['error']['message']);
  case 422:
    final campo = corpo['error']['field'];            // ex.: 'answers.nome'
    destacarPergunta(campo.split('.').last, corpo['error']['message']);
  case 401:
    irParaLogin();
}

Construtor no admin — ao salvar a edição, devolva cada pergunta existente com o mesmo id; pergunta sem id é criada como nova:

await http.put(
  Uri.parse('$baseUrl/api/v1/admin/forms/$formId'),
  headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $adminToken'},
  body: jsonEncode({
    'title': 'Pesquisa de satisfação 2026',
    'fields': [
      {'id': 'nome', 'label': 'Nome', 'type': 'short_text', 'required': true},
      {'label': 'Pergunta nova', 'type': 'scale', 'max_value': 10},  // sem id = nova
    ],
  }),
);

O link para compartilhar vem pronto em public_url na listagem (GET /admin/forms); quando vier null, o ambiente ainda não tem FORMS_PUBLIC_BASE_URL configurada.