# Webhooks

> Para quem integra. Como a Memberfy avisa o seu sistema quando algo acontece na comunidade, o formato do envelope, a assinatura Standard Webhooks e como verificá-la em Node, desduplicação, ordem, novas tentativas, desligamento automático e os limites.

Um webhook é a Memberfy a chamar o seu sistema: quando alguém entra, compra, subscreve, se inscreve num evento ou conclui um curso, a Memberfy envia um `POST` para o URL que registou, assinado, de imediato. É o caminho inverso da [API](/api/introducao), em que é o seu sistema que pergunta.

Quem cria o webhook é o proprietário ou um administrador da comunidade, em **Definições › Integrações e APIs**, separador **Webhooks**. Ver [Integrações & APIs › Webhooks](/configuracoes/integracoes-e-apis#webhooks). Este guia é para quem escreve o código que recebe.

## O que chega

Um `POST` por evento e por webhook, com `Content-Type: application/json; charset=utf-8` e `User-Agent: Memberfy-Webhooks/1`. O corpo é sempre o mesmo envelope:

```json
{
  "id": "evt_01JAB3K7Q9X2M4N6P8R0S2T4V6",
  "type": "purchase.completed",
  "version": 1,
  "occurredAt": "2026-10-19T14:03:11.402Z",
  "community": { "id": "c3f1a2b4-5d6e-4f70-8a9b-0c1d2e3f4a5b", "slug": "apicon" },
  "data": {
    "member": { "id": "9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "name": "Maria Souza", "email": "maria@memberfy.net", "phone": "+5541999998888", "phoneCountry": "BR" },
    "purchaseId": "…",
    "product": { "id": "…", "name": "Plano Pro", "type": "SUBSCRIPTION" },
    "amount": { "amount": 9990, "currency": "BRL" }
  }
}
```

| Campo | O que é |
|---|---|
| `id` | O identificador do evento. É o mesmo do cabeçalho `webhook-id`, em todas as tentativas e no reenvio |
| `type` | O evento (`member.joined`, `purchase.completed`…) ou `ping`, o teste |
| `version` | A versão do formato de `data` desse evento. Hoje, `1` em todos |
| `occurredAt` | Quando o facto aconteceu na Memberfy (ISO 8601, UTC), não quando a entrega saiu |
| `community` | A comunidade: `id` e `slug` |
| `data` | O que aconteceu. Muda por evento; ver [Eventos de webhook](/api/eventos-de-webhook) |

Os valores em dinheiro vêm em cêntimos, como `{ "amount": 9990, "currency": "BRL" }`, sempre o que a pessoa pagou. A pessoa envolvida vem em `data.member`, com `id`, `name`, `email`, `phone` (no formato E.164, como `+5541999998888`, ou `null`) e `phoneCountry` (o país do telefone em ISO 3166-1 alfa-2, como `BR`, ou `null`).

## Os eventos

São 97 eventos em 27 famílias, mais o `ping`:

| Família | Eventos |
|---|---|
| `member` | `member.joined` (incluindo de quem é importado por folha de cálculo, com `source: "import"`), `member.left`, `member.returned`, `member.role_changed`, `member.tag_added`, `member.tag_removed`, `member.group_joined`, `member.group_left`, `member.field_changed`, `member.level_changed`, `member.points_changed` |
| `purchase` | `purchase.completed`, `purchase.refunded` |
| `subscription` | `subscription.created`, `subscription.renewed`, `subscription.canceled`, `subscription.ended`, `subscription.plan_changed`, `subscription.past_due`, `subscription.trial_ending` |
| `event` | `event.registered` (com as respostas da inscrição em `registration`), `event.checked_in`, `event.check_in_undone`, `event.ticket_assigned`, `event.registration_canceled`, `event.waitlist_offered`, `event.created`, `event.updated`, `event.deleted`, `event.stream_started`, `event.recording_published`, `event.rescheduled` |
| `course` | `course.enrolled`, `course.completed`, `course.published`, `course.updated`, `course.left` |
| `certificate` | `certificate.issued`, `certificate.revoked` |
| `form` | `form.submitted`, `form.submission_reviewed`, `form.published`, `form.closed`, `form.reopened` |
| `lesson` | `lesson.completed`, `lesson.rated`, `lesson.published` |
| `comment` | `comment.created`, `comment.deleted` |
| `reaction` | `reaction.added`, `reaction.removed` |
| `post` | `post.created`, `post.updated`, `post.moved`, `post.deleted` |
| `checkout` | `checkout.started`, `checkout.abandoned`, `checkout.recovered` |
| `space` | `space.first_visit`, `space.access_granted`, `space.created`, `space.updated`, `space.deleted` |
| `quiz` | `quiz.passed`, `quiz.failed` |
| `coupon` | `coupon.redeemed` |
| `survey` | `survey.answered` |
| `proposal` | `proposal.submitted`, `proposal.decided` |
| `upvote` | `upvote.topic_created`, `upvote.topic_status_changed` |
| `download` | `download.completed`, `download.item_published`, `download.item_updated`, `download.item_moved`, `download.file_added`, `download.file_replaced`, `download.file_removed`, `download.item_deleted`, `download.category_created` |
| `classified` | `classified.published`, `classified.interest_received` |
| `parcel` | `parcel.received`, `parcel.picked_up` |
| `visitor` | `visitor.invited`, `visitor.arrived` |
| `attendance` | `attendance.checked_in`, `attendance.checked_out` |
| `support_ticket` | `support_ticket.created`, `support_ticket.resolved` |
| `booking` | `booking.created`, `booking.canceled`, `booking.checked_in`, `booking.no_show` |
| `message` | `message.received` (sem o texto da mensagem) |
| `badge` | `badge.awarded`, `badge.revoked` |
| (teste) | `ping`, enviado pelo botão **Enviar teste** e ao religar, com `data: { "message": "…" }` |

O webhook subscreve eventos um a um ou a família inteira (`member.*`); quem marca a família recebe também os eventos que nela entrarem depois. Os campos de cada um estão em [Eventos de webhook](/api/eventos-de-webhook).

Um convite ainda não aceite não gera `member.joined`.

### Níveis e pontos

`member.level_changed` sai quando o [nível](/membros/gamificacao#niveis) da pessoa muda:

```json
{
  "member": { "id": "…", "name": "Maria Souza", "email": "maria@memberfy.net" },
  "direction": "down",
  "cause": "activity",
  "from": { "id": "…", "level": 5, "name": "Kernel" },
  "to": { "id": "…", "level": 4, "name": "Shell" },
  "points": 640
}
```

- `direction`: `up` (subiu, de imediato) ou `down` (desceu).
- `cause`: `points` (os pontos mudaram o nível) ou `activity` (os requisitos da semana: cumpridos, na subida, ou não cumpridos na semana anterior, na descida de segunda-feira às 00:00 no fuso horário da comunidade).
- `from` e `to`: o nível antes e depois, com `id`, `level` (o número, 1 é o primeiro) e `name`. O `id` vem `null` enquanto a comunidade usa os níveis predefinidos sem nunca os ter aberto.
- Saltar vários níveis de uma vez gera **um** evento, com o nível final em `to`.

`member.points_changed` sai quando o total de pontos da pessoa na comunidade muda: `from`, `to`, `delta` e `source` (`action`, uma ação da pessoa; `reversal`, pontos devolvidos porque a ação foi desfeita; `team`, um ajuste da equipa; `workflow`, um ajuste de workflow).

`space.access_granted` sai quando a pessoa ganha acesso a um espaço, com `source`: `purchase` (uma compra), `plan` (uma subscrição), `team` (a equipa) ou `badge` (a pessoa ganhou um badge que abre o espaço; uma secção que abre conta cada espaço dela). Nunca sai quando o acesso é retirado.

Nenhum dos dois sai num recálculo de pontos, numa importação, quando a comunidade muda os níveis ou com a gamificação desligada.

### O que nunca vai

Nenhum evento leva taxa, valor líquido, dados do processador de pagamento ou do cartão, CPF/CNPJ do registo, morada, data de nascimento, campos personalizados, palavra-passe, token ou segredo, IP, nem o texto de publicações, comentários e pedidos de suporte. As respostas de um formulário (`form.submitted`) e das perguntas da inscrição de um evento (`event.registered`) vão como a pessoa as preencheu, incluindo o campo **Documento**. O nome, o e-mail e o telefone da pessoa (quando o registou) vão sempre: por isso o ecrã avisa, ao criar o webhook, que seguem dados pessoais para o destino.

## Verificar a assinatura

Toda a entrega vai assinada no padrão [Standard Webhooks](https://www.standardwebhooks.com), com o segredo do webhook (começa por `whsec_` e aparece uma única vez, na criação ou ao rodar). Três cabeçalhos:

| Cabeçalho | O que é |
|---|---|
| `webhook-id` | O id do evento (o mesmo `id` do corpo) |
| `webhook-timestamp` | Quando a tentativa saiu, em segundos desde 1970 |
| `webhook-signature` | `v1,` seguido do HMAC-SHA256, em base64, de `{webhook-id}.{webhook-timestamp}.{corpo}` |

Verifique **sempre** antes de confiar no conteúdo, com o corpo exatamente como chegou (sem fazer o parse antes), e recuse um `webhook-timestamp` com mais de 5 minutos de diferença do seu relógio. A biblioteca [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) faz as duas coisas. Em Node, com Express:

```js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const app = express();
const webhook = new Webhook(process.env.MEMBERFY_WEBHOOK_SECRET); // whsec_…

// O corpo cru: a assinatura é sobre os bytes que chegaram.
app.post('/memberfy', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body.toString('utf8'), {
      'webhook-id': req.get('webhook-id'),
      'webhook-timestamp': req.get('webhook-timestamp'),
      'webhook-signature': req.get('webhook-signature'),
    });
  } catch {
    return res.status(400).send('invalid signature');
  }

  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // deduplicar
  res.sendStatus(200); // responda logo e processe depois
  await queue.add(event); // ex.: a sua fila de trabalho
});

app.listen(3000);
```

O `verify` verifica a assinatura e a hora e devolve o envelope já lido. Existem bibliotecas do mesmo padrão para Python, Go, Ruby, PHP, Java e outras linguagens.

Ao **rodar o segredo**, o novo vale de imediato e o antigo deixa de valer: atualize o seu lado logo a seguir.

## Responder e processar

- Responda com qualquer `2xx` em até **10 segundos**. Passado esse tempo, a tentativa conta como falha.
- Processe depois de responder (uma fila, um job): uma integração lenta não pode atrasar a resposta.
- **Desduplique pelo `webhook-id`.** O mesmo evento pode chegar mais de uma vez (uma resposta que se perdeu na rede, um reenvio pelo ecrã): guarde os ids já processados e ignore o repetido.
- **A ordem não é garantida.** Um `subscription.renewed` pode chegar antes do `subscription.created` que ficou em nova tentativa. Use o `occurredAt` para ordenar e, quando precisar do estado atual, pergunte à [API](/api/introducao).
- Os redirecionamentos não são seguidos: um `301` ou `302` conta como falha. Registe o URL final.

## Novas tentativas

Sem um `2xx`, a Memberfy tenta de novo. São até **8 tentativas** por entrega: a primeira de imediato e as outras ao fim de **1 min, 5 min, 30 min, 2 h, 6 h, 12 h e 24 h**, cerca de 45 horas no total. Esgotadas as 8, a entrega fica como **Falhou** no histórico, e é possível **reenviá-la** pelo ecrã (um ciclo novo, com o mesmo `webhook-id`).

| A resposta | O que acontece |
|---|---|
| `2xx` | Entregue |
| `410 Gone` | O webhook é **desligado de imediato**, sem mais tentativas, e o proprietário e os administradores recebem um e-mail |
| `429` | Tenta de novo depois do `Retry-After` (em segundos ou como data), se existir; **não conta como falha** |
| Qualquer outro estado, tempo esgotado ou erro de rede | Falha; segue o calendário acima |

### Desligamento automático

Se o destino ficar **72 horas sem nenhuma entrega com sucesso** (com pelo menos 5 falhas no período), o webhook é desligado (**Desligado por falha**), as tentativas pendentes são canceladas e o proprietário e os administradores recebem um e-mail. Os eventos que acontecerem enquanto estiver desligado não são enviados depois.

Para voltar, use **Religar** no ecrã: a Memberfy envia um `ping` antes, e o webhook só volta se o destino responder `2xx`.

## Endereço e rede

- Só **`https`**, na porta **443**, num endereço público. Endereços internos, de rede privada ou de loopback são recusados.
- Sem redirecionamento e com **10 segundos** de limite por tentativa. Da resposta, a Memberfy guarda só o início (1 KB), para o histórico.
- Se a sua firewall só aceita endereços conhecidos, marque **Enviar de IPs fixos** no webhook e autorize os [IPs fixos da Memberfy](/api/ips-fixos).

## Limites

| Limite | Valor |
|---|---|
| Webhooks por comunidade | 10 |
| Criar, enviar teste, religar, reenviar e trocar o URL | 10 por minuto por comunidade, somados; acima disso, `429` com `Retry-After` |
| Histórico de entregas e de alterações | 30 dias |

Cada entrega com sucesso de um evento conta em **Webhooks entregues**, no bloco **Uso** de [Integrações & APIs](/configuracoes/integracoes-e-apis#o-bloco-uso). O `ping` não conta.

## Gerir por API

O ecrã usa a API de webhooks em `https://developers.memberfy.net` (`/api/webhooks`: listar, criar, editar o URL, a descrição, os eventos e os IPs fixos, enviar teste, rodar, desligar, religar, apagar, entregas, reenvio e histórico). Aceita só o token de início de sessão (`Authorization: Bearer`) com o `X-CommunityId`, de proprietário ou administrador; **a chave de API não entra**. A especificação está em [developers.memberfy.net/docs.json](https://developers.memberfy.net/docs.json). O catálogo de eventos é público: [`GET /api/integrations/events`](/api/referencia/integrations).

### Editar um webhook

`PATCH /api/webhooks/{id}` só com o que muda; um campo ausente fica como está.

```bash
curl -X PATCH https://developers.memberfy.net/api/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-CommunityId: $COMMUNITY_ID" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.memberfy.net/crm-v2", "events": ["member.*", "purchase.completed"], "fixedIps": true}'
```

| Campo | Tipo | Observação |
|---|---|---|
| `url` | texto (até 2048) | A mesma verificação da criação (`https`, porta 443, endereço público); entra no limite de 10 por minuto por comunidade |
| `description` | texto (até 200) ou `null` | |
| `events` | lista (1 a 50) | Tipos ou famílias inteiras (`member.*`) |
| `fixedIps` | booleano | Liga ou desliga **Enviar de IPs fixos** |

- A configuração nova vale para os **eventos que acontecerem depois**. As entregas pendentes, em nova tentativa ou reenviadas depois seguem como foram criadas: o mesmo URL, o mesmo uso de IP fixo e o mesmo payload. A assinatura usa sempre o segredo atual.
- Trocar o `url` põe o `failureCount` a zero.
- **Editar não religa**: um webhook `DISABLED` ou `DISABLED_FAILING` continua desligado. Religar é `POST /api/webhooks/{id}/enable`, que envia o teste e só religa com resposta 2xx.
- Cada edição que muda algo torna-se uma linha `updated` no histórico, com o antes e o depois de cada campo em `changes` (URL mascarados). Uma edição sem mudanças não gera linha.
- Respostas: `200` com o webhook, `404`, `422` (campo inválido, em `errors[].param`) e `429`.

### Integrações prontas

As [integrações prontas](/integracoes/integracoes-prontas) (Discord, Slack, Telegram, RD Station, HubSpot) usam a mesma API, em `/api/apps`: `GET /api/apps` (catálogo, com a instalação de cada app), `PUT /api/apps/{slug}/installation` (liga ou guarda a configuração e os eventos), `POST …/installation/test`, `DELETE …/installation` (desliga; com `?remove=true`, remove e apaga a configuração e as entregas), `POST /api/apps/telegram/chats` (*Encontrar meu grupo*) e as entregas em `…/installation/deliveries`, com reenvio. As mesmas regras de acesso: token de início de sessão de proprietário ou administrador, nunca chave de API. Os campos secretos voltam mascarados; num `PUT`, um campo secreto ausente mantém o valor guardado.

## Relacionados

- [Eventos de webhook](/api/eventos-de-webhook)
- [IPs fixos](/api/ips-fixos)
- [Integrações & APIs](/configuracoes/integracoes-e-apis#webhooks)
- [Autenticação](/api/autenticacao)
