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:
POST /api/v1/forms/{slug}/upload—multipart/form-datacomfileefield_id. Devolve{file_id, filename, content_type, size}.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_urlprontas para usar; os camposbackground_head/_pageguardam só ofile_id - ⚠️ Corrigido em 21/09: entre 20 e 21/09 a URL saía sem o prefixo
/api/v1e 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 headerAuthorization
🔗 A página do link (ADR-0143)¶
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. Sembackground_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 incluindopurpose— 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
purposeforstandalone(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_ROUTESe 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 deapp.routestoda rota self-service de auth manual e roda oAuthControlreal.
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 emform_response_id.
1. GET /api/v1/forms/{slug} — Ler pelo link¶
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¶
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:
| 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);nullse o ambiente não tiver a URL base configuradaresponses_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_usersemlogin,opens_atdepois decloses_at, pergunta incoerente com o tipo)
5. Listar, excluir e resumir respostas¶
GET /admin/forms/{form_id}/responses?start=&end=&qty_docs_page=50¤t_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_urlsai deFORMS_PUBLIC_BASE_URLquando configurada; sem ela, doDOMAINdo ambiente +/f— a página que a própria API serve. Para trocar pela tela do app, basta apontar[<ambiente>.forms] public_base_urlpara ele: é config, não deploy. - Permissões:
rootacessa direto. Admin com perfil precisa dos handlersadmin_forms_get,admin_forms_post,admin_forms_put,admin_forms_delete,admin_form_duplicate_post,admin_form_responses_get,admin_form_responses_deleteeadmin_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, useerror.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.