# Webhooks

> Payloads de webhook assinados, verificação da assinatura HMAC-SHA256, a agenda de novas tentativas e os logs de entrega.

Os webhooks repassam cada envio aceito para o seu servidor assim que ele chega. As entregas são assinadas com o esquema [Standard Webhooks](https://www.standardwebhooks.com), têm novas tentativas por cerca de três dias e ficam registradas tentativa por tentativa no painel. Há handlers prontos em [Recebendo webhooks](https://ikiform.com/pt-br/docs/guides/webhook-handlers.md).

## Adicione um endpoint

1. No painel, abra **Webhooks** e escolha **Add endpoint**.
2. Informe uma URL HTTPS e escolha um formulário, ou todos os formulários do workspace.
3. Copie o segredo de assinatura. Ele começa com `whsec_` e só é exibido por completo uma vez.
4. Envie um evento de teste para conferir o seu handler.

### URLs de endpoint

- Só `https:`, na porta padrão 443, sem usuário ou senha na URL, com até 2.048 caracteres.
- Um nome de host público. Endereços IP, `localhost` e nomes terminados em `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa` ou `.onion` são recusados, assim como ikiform.com, workers.dev e pages.dev.
- O nome de host precisa resolver só para endereços públicos. Faixas privadas, de loopback, link-local e de NAT carrier-grade são recusadas.
- A URL é verificada quando você a salva e de novo antes de cada entrega. Um endpoint que passa a resolver para um endereço privado é desativado.
- Um workspace pode ter até 3 endpoints. O Enterprise não tem limite.

## Eventos

| Tipo | Enviado quando | Novas tentativas |
| --- | --- | --- |
| `submission.created` | Um envio é aceito, ou um envio marcado como spam é marcado como não spam | Sim |
| `endpoint.test` | Você escolhe **Send test** na página do endpoint | Não, uma tentativa |

Spam nunca dispara um webhook. Envios retidos porque o workspace passou do limite mensal também não.

## A requisição

Cada entrega é um `POST` com corpo JSON e três headers de assinatura, enviado pelo serviço de entrega do Ikiform com o user agent `Ikiform-Webhooks/1.0`.

`Headers`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| Header | Valor |
| --- | --- |
| `webhook-id` | O id da entrega. É o mesmo em toda nova tentativa da mesma entrega, então use-o para ignorar duplicados |
| `webhook-timestamp` | Segundos Unix de quando esta tentativa foi assinada. Novo a cada tentativa |
| `webhook-signature` | `v1,` mais um HMAC-SHA256 em base64. Vários valores separados por espaço enquanto um segredo é trocado |

### Payloads

**submission.created** (`Corpo`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Corpo`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "type": "endpoint.test",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "endpoint_id": "whk_01k7v2e1b3d5f7h9k1m3n5q7s9",
    "message": "This is a test event from Ikiform."
  }
}
```

**Truncado** (`Acima de 256 KB`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| Campo | Descrição |
| --- | --- |
| `id` | O id da entrega, igual a `webhook-id` |
| `type` | `submission.created` ou `endpoint.test` |
| `timestamp` | Quando a entrega foi criada, em ISO 8601 UTC. O mesmo em toda nova tentativa |
| `api_version` | A versão do payload com que o endpoint foi criado, atualmente `2026-10-17` |
| `data.submission_id` | O id do envio. Busque-o com a [API REST](https://ikiform.com/pt-br/docs/api.md#get-submission) |
| `data.form` | O `id` e o `name` do formulário |
| `data.fields` | Os valores armazenados. Com campos definidos, são os valores validados e convertidos |
| `data.meta` | As mesmas chaves do `meta` da API REST. Todas as chaves estão presentes e qualquer valor pode ser `null`. O IP do visitante e o hash dele nunca são enviados |
| `data.meta.user_agent` | O user agent do navegador, com até 512 caracteres |
| `data.meta.origin`, `referrer` | De onde o envio veio, quando o navegador os enviou |
| `data.meta.country` | Código de país com duas letras, vindo da Cloudflare |
| `data.meta.subject`, `reply_to` | Dos campos `_subject` e `_replyto` |
| `data.created_at` | Quando o envio foi armazenado |
| `data.truncated` | `true` quando o corpo passaria de 256 KB, caso em que `fields` fica vazio |

Qualquer valor de `meta` pode ser `null`. Trate os valores dos campos como entrada não confiável, como um envio de formulário para o seu próprio servidor.

## Verifique a assinatura

A assinatura é um HMAC-SHA256 de `{webhook-id}.{webhook-timestamp}.{raw body}`, usando como chave a parte do segredo depois de `whsec_` decodificada de base64, depois codificado em base64 e enviado após `v1,` em `webhook-signature`.

- Use o corpo bruto da requisição exatamente como foi recebido. Fazer parse e serializar o JSON de novo muda os bytes.
- O header pode conter várias assinaturas separadas por espaço. Aceite a requisição se qualquer assinatura `v1` corresponder.
- Compare em tempo constante e rejeite timestamps a mais de 5 minutos do horário atual.

### Com o SDK

`verifyWebhook` faz tudo isso com Web Crypto e retorna o evento já interpretado, ou lança `WebhookVerificationError` com um `reason`. Ele aceita um objeto `Headers` ou um record simples, como o `req.headers` do Node.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### Sem o SDK

Qualquer biblioteca de Standard Webhooks também funciona. Na mão, são poucas linhas:

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Verificador de assinatura

O verificador interativo desta página confere um segredo, os três headers do webhook e um corpo bruto com o `verifyWebhook` do SDK, inteiramente no navegador. Ele também pode gerar uma entrega de exemplo assinada corretamente. Nada do que você cola sai da página.

## Respondendo

| Sua resposta | O que o Ikiform faz |
| --- | --- |
| Qualquer `2xx` em até 15 segundos | Marca a entrega como bem-sucedida |
| `410 Gone` | Marca a entrega como falha e desativa o endpoint na hora |
| `429` ou `503` com `Retry-After` | Tenta de novo depois desse intervalo, mas não depois da próxima etapa da agenda |
| Um redirecionamento | Não é seguido. Conta como falha |
| Qualquer outro status, um timeout ou um erro de rede | Novas tentativas conforme a agenda abaixo |

Faça o trabalho demorado depois de responder, por exemplo em uma fila. Os primeiros 4 KB do corpo da sua resposta ficam salvos no log de entrega.

## Novas tentativas

Uma tentativa que falha é repetida nesta agenda, cada intervalo com até 20% de jitter aleatório para cada lado. Depois da 9ª tentativa, cerca de 3 d 6 h após a primeira, a entrega é marcada como falha.

| Tentativa | Intervalo após a tentativa anterior | Tempo desde a primeira tentativa |
| --- | --- | --- |
| 1 | Na hora | 0 |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min |
| 4 | 30 min | 35 min |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 1 d | 1 d 18 h |
| 9 | 1 d 12 h | 3 d 6 h |

### Endpoints desativados

Se todas as entregas para um endpoint falharem por 5 dias, o endpoint é desativado e os proprietários do workspace recebem um e-mail. Uma resposta `410 Gone` ou um destino que passa a ser privado o desativa na hora. Reative-o na página do endpoint depois de corrigir.

## Logs de entrega

A página de cada endpoint lista as entregas com o status de cada uma. Expanda uma para ver todas as tentativas: quando rodou, se foi automática, manual ou um teste, o código de status, a duração e os primeiros 4 KB da sua resposta ou o erro. Reenvie qualquer entrega a partir dali. As tentativas ficam guardadas por 30 dias e as entregas por 90 dias.

## Troca de segredos

**Roll signing secret** cria um novo segredo. Nas 24 horas seguintes, toda entrega é assinada com o segredo antigo e com o novo, então `webhook-signature` contém dois valores e você pode fazer deploy do novo segredo sem perder eventos.
