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, 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. 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:
{
"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": "[email protected]", "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 |
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.
Um convite ainda não aceite não gera member.joined.
Níveis e pontos
member.level_changed sai quando o nível da pessoa muda:
{
"member": { "id": "…", "name": "Maria Souza", "email": "[email protected]" },
"direction": "down",
"cause": "activity",
"from": { "id": "…", "level": 5, "name": "Kernel" },
"to": { "id": "…", "level": 4, "name": "Shell" },
"points": 640
}
direction:up(subiu, de imediato) oudown(desceu).cause:points(os pontos mudaram o nível) ouactivity(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).frometo: o nível antes e depois, comid,level(o número, 1 é o primeiro) ename. Oidvemnullenquanto 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).
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, 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 faz as duas coisas. Em Node, com Express:
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
2xxem 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.renewedpode chegar antes dosubscription.createdque ficou em nova tentativa. Use ooccurredAtpara ordenar e, quando precisar do estado atual, pergunte à API. - Os redirecionamentos não são seguidos: um
301ou302conta 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.
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. 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. O catálogo de eventos é público: GET /api/integrations/events.
Editar um webhook
PATCH /api/webhooks/{id} só com o que muda; um campo ausente fica como está.
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
urlpõe ofailureCounta zero. - Editar não religa: um webhook
DISABLEDouDISABLED_FAILINGcontinua 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
updatedno histórico, com o antes e o depois de cada campo emchanges(URL mascarados). Uma edição sem mudanças não gera linha. - Respostas:
200com o webhook,404,422(campo inválido, emerrors[].param) e429.
Integrações prontas
As integrações 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.