# Adicione um formulário de contato a um site no Cloudflare Pages

> Adicione um formulário de contato ao Cloudflare Pages sem escrever uma Function: envie para um backend de formulários, defina headers no _headers e use suas próprias chaves do Turnstile.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/contact-form-static-site/cloudflare-pages

Assets estáticos no Cloudflare Pages respondem a um `POST` com `405`, então ou você escreve uma Pages Function ou envia o formulário para um backend de formulários. Com o Ikiform você não escreve Function nenhuma: defina o `action` do formulário como o seu endpoint, adicione `your-project.pages.dev` em **Allowed domains**, o que também cobre todas as URLs de preview, e inclua o Ikiform na Content-Security-Policy do seu `_headers`. Se você já usa a Cloudflare, pode colocar suas próprias chaves do Turnstile no formulário.

## O que muda no Cloudflare Pages

O Pages serve o resultado do seu build pela rede da Cloudflare. Assets estáticos respondem a `GET`; um `POST` para um deles recebe `405` sem corpo:

`Resposta a um POST`:

```http
HTTP/2 405
server: cloudflare
content-length: 0
```

O Pages pode rodar código, por meio de Pages Functions numa pasta `functions/`. Um `functions/contact.ts` poderia aceitar o envio, mas aí você está escrevendo um backend: parsing, validação, um provedor de e-mail, filtro de spam e armazenamento. A própria documentação da Cloudflare também recomenda começar projetos novos no Workers em vez do Pages. Enviar para um backend de formulários evita as duas coisas, e o mesmo formulário funciona sem mudanças se você migrar depois para os static assets do Workers.

| Característica do Pages | O que significa para o formulário |
| --- | --- |
| URLs de preview são subdomínios, como `abc123.your-project.pages.dev` | Um único domínio permitido, `your-project.pages.dev`, cobre a produção e todos os previews. Os previews da Vercel e da Netlify precisam de entradas próprias |
| Os headers vêm de um arquivo `_headers` no resultado do build | Até 100 regras, 2.000 caracteres por linha. Eles não se aplicam a respostas de Pages Functions |
| O Turnstile é um produto da Cloudflare | Você pode usar a verificação gerenciada do Ikiform sem chaves, ou seu próprio widget a partir do painel da Cloudflare |

Diagrama: Um formulário de contato no Cloudflare Pages com suas próprias chaves do Turnstile

1. O Pages serve a página de contato, com a Content-Security-Policy do seu arquivo _headers.
2. O widget do Turnstile roda com a sua site key e coloca um token no formulário.
3. O formulário envia os campos e o token ao Ikiform.
4. O Ikiform verifica o token com a sua secret key e confere o hostname do token com os seus domínios permitidos.
5. Um envio válido é armazenado e enviado por e-mail. Um token ausente, expirado ou reutilizado recebe 403 captcha_failed, e a página reinicia o widget.

Com a verificação gerenciada do Ikiform, não há widget na sua página; o formulário HTML não precisa de mudança nenhuma.

## O formulário

Esta versão usa o seu próprio widget do Turnstile, o que faz sentido quando você já gerencia o Turnstile na sua conta da Cloudflare. Nas configurações do formulário, defina **Spam check** como **Your own Turnstile keys** e cole a site key e a secret. O widget adiciona um campo `cf-turnstile-response` ao formulário, e o select `team` diz quem deve responder:

`public/contact.html`:

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>
```

O script mantém os visitantes na página. Um token do Turnstile é de uso único, então depois de qualquer envio que falhe ele reinicia o widget para a próxima tentativa:

`public/contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
```

- Nas configurações do widget do Turnstile na Cloudflare, liste os hostnames em que o formulário roda. O Ikiform também confere o hostname do token com os **Allowed domains** do formulário.
- Envios sem um token válido são recusados com `403 captcha_failed`. Isso inclui um envio de HTML simples feito antes de o widget terminar.
- Prefere não ter chave nenhuma? Defina **Spam check** como **Ikiform**, apague o widget e o script dele, e o envio HTML roda a verificação em ikiform.com. A versão com `fetch` passa a precisar de `challenge()` do [SDK](https://ikiform.com/pt-br/docs/sdk.md); veja [a verificação humana](https://ikiform.com/pt-br/docs/spam.md#human-check).

## Suas chaves ou a verificação do Ikiform

|   | Verificação do Ikiform | Suas próprias chaves do Turnstile |
| --- | --- | --- |
| Chaves para gerenciar | Nenhuma | Uma site key e uma secret do seu painel da Cloudflare |
| Mudança no formulário HTML | Nenhuma. A verificação roda numa página de ikiform.com | Adicionar o widget e o script dele |
| Versão com `fetch` | Chamar `challenge()` do SDK | Enviar o `cf-turnstile-response` do widget |
| Content-Security-Policy | Só `form-action` e `connect-src` para o Ikiform | Também `challenges.cloudflare.com` em `script-src` e `frame-src` |
| Analytics do Turnstile | Não na sua conta | No seu painel da Cloudflare, junto com os seus outros widgets |

A verificação do Ikiform dá menos trabalho para configurar. Suas próprias chaves fazem sentido quando você já acompanha o analytics do Turnstile, ou quer controlar a aparência e a posição do widget.

### Testando o widget localmente

A Cloudflare publica site keys e secrets de teste que sempre passam ou sempre falham, como o par que sempre passa `1x00000000000000000000AA` e `1x0000000000000000000000000000000AA`. Secrets de produção rejeitam tokens gerados com uma chave de teste, então não misture as duas num formulário. Crie um segundo formulário no Ikiform para o trabalho local, dê a ele o par de chaves de teste e troque o `data-sitekey` e o id do formulário juntos. Deixe **Allowed domains** desse formulário vazio: o Ikiform só confere o hostname de um token quando a lista está definida, e a Cloudflare não documenta qual hostname um token de teste informa.

## O arquivo _headers

Coloque o `_headers` no seu diretório de assets estáticos, como `public/`, para ele acabar no resultado do build. O Turnstile precisa de `script-src` e `frame-src` para `https://challenges.cloudflare.com`, e o Ikiform precisa de `form-action` e `connect-src`:

`public/_headers`:

```text
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Se depois você adicionar uma Pages Function ao mesmo projeto, as respostas dela não recebem esses headers. Defina-os no código da Function.

## Se você migrar para o Workers

Migrar o site para os static assets do Workers muda como ele faz deploy, não como o formulário funciona. O formulário continua enviando para ikiform.com, então nada do lado da Cloudflare o trata. Confira duas coisas depois da migração: o novo host `workers.dev` ou a rota está em **Allowed domains**, e os seus headers ainda chegam à página, já que o Workers tem o próprio jeito de defini-los.

> Adicione `your-project.pages.dev` e o seu domínio personalizado em **Allowed domains**. Nunca adicione `pages.dev` sozinho: todo projeto do Pages passaria a corresponder.

## Confira se funciona

1. Faça push de uma branch e abra a URL de preview dela. Envie e confirme que a mensagem chega e não é marcada como spam: o preview é um subdomínio do host do seu projeto.
2. Envie de novo em produção, depois volte e envie o mesmo formulário uma segunda vez sem completar o widget. Espere a mensagem de reinício, não um envio armazenado.
3. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você enviou.
4. Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e **Reply** vai para quem enviou quando o formulário tem um campo `email`.
5. Não chegou nada? Mude o filtro de status do painel para **Spam**. Spam recebe a mesma resposta de sucesso de um envio real.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `405` com corpo vazio | O formulário enviou para uma URL do Pages. Confira o `action` publicado, e se nenhuma Function ou regra de redirecionamento reescreve `/contact`. |
| O widget do Turnstile nunca aparece | A Content-Security-Policy bloqueia `challenges.cloudflare.com`. Adicione-o a `script-src` e `frame-src`, e confira se o `_headers` está no resultado do build. |
| `403 captcha_failed` em todo envio | A site key e a secret vêm de widgets diferentes, o hostname não está listado no widget, ou uma secret de teste está combinada com uma site key real. |
| `403 origin_not_allowed`, ou todo envio cai no spam | O domínio da página não está em **Allowed domains** do formulário. Adicione-o, mais `localhost` para testes locais. Subdomínios de um domínio listado são permitidos. |

## Perguntas

### Preciso de Pages Functions para um formulário de contato no Cloudflare Pages?

Não. Uma Function é uma forma de aceitar o envio, mas aí você mesmo escreve a validação, o envio de e-mail e o filtro de spam. Enviar para um backend de formulários mantém o projeto totalmente estático.

### Os deploys de preview precisam de um domínio permitido próprio?

Não. As URLs de preview do Pages são subdomínios de your-project.pages.dev, então permitir esse host cobre todas elas.

### Posso usar meu widget do Turnstile existente com o Ikiform?

Sim. Defina a verificação de spam do formulário como suas próprias chaves do Turnstile e cole a site key e a secret. O Ikiform verifica cada token e confere o hostname dele com os seus domínios permitidos.

### Por que meu formulário recebe 405 no Cloudflare Pages?

Ele enviou para a sua própria URL do Pages, que serve assets estáticos que só respondem a requisições GET. Aponte o action do formulário para um backend de formulários, ou adicione uma Pages Function para esse caminho.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Formulário de contato em site estático](https://ikiform.com/pt-br/guides/contact-form-static-site.md): Coloque um formulário de contato funcionando num site estático sem servidor: para onde vai o POST, spam, respostas por e-mail e código para todo host e framework.
- [Vercel](https://ikiform.com/pt-br/guides/contact-form-static-site/vercel.md): Uma página estática na Vercel responde a um POST de formulário com 405 Method Not Allowed; veja por quê e como enviar o formulário para um backend.
- [Netlify](https://ikiform.com/pt-br/guides/contact-form-static-site/netlify.md): Rode um formulário de contato num site da Netlify sem o Netlify Forms: sem detecção no build, envios JSON a partir do React e um formulário separado para deploy previews.
- [GitHub Pages](https://ikiform.com/pt-br/guides/contact-form-static-site/github-pages.md): O GitHub Pages não roda código de servidor, então um POST de formulário recebe 405 Not Allowed; envie para um backend de formulários e acerte os redirecionamentos de project sites.
- [Amazon S3](https://ikiform.com/pt-br/guides/contact-form-static-site/aws-s3.md): Endpoints de site do S3 só aceitam GET e HEAD via HTTP simples, então envie o formulário de contato para um backend de formulários e acerte os redirecionamentos com o CloudFront.
- [Site de portfólio](https://ikiform.com/pt-br/guides/contact-form-static-site/portfolio-website.md): Um formulário de contato para portfólio que faz as perguntas de uma primeira conversa: projeto, orçamento, prazo e um link, sem servidor e com respostas pela sua caixa de entrada.
