Webhooks
Para quien integra. Cómo Memberfy avisa a tu sistema cuando algo ocurre en la comunidad, el formato del sobre, la firma Standard Webhooks y cómo verificarla en Node, deduplicación, orden, reintentos, desactivación automática y los límites.
Un webhook es Memberfy llamando a tu sistema: cuando alguien entra, compra, se suscribe, se inscribe en un evento o termina un curso, Memberfy envía un POST a la URL que registraste, firmado, en el acto. Es el camino inverso de la API, en la que es tu sistema quien pregunta.
El webhook lo crea el propietario o un administrador de la comunidad, en Configuración › Integraciones y APIs, pestaña Webhooks. Ver Integraciones y APIs › Webhooks. Esta guía es para quien escribe el código que lo recibe.
Qué llega
Un POST por evento y por webhook, con Content-Type: application/json; charset=utf-8 y User-Agent: Memberfy-Webhooks/1. El cuerpo es siempre el mismo sobre:
{
"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": "Plan Pro", "type": "SUBSCRIPTION" },
"amount": { "amount": 9990, "currency": "BRL" }
}
}
| Campo | Qué es |
|---|---|
id | El identificador del evento. Es el mismo de la cabecera webhook-id, en todos los intentos y en el reenvío |
type | El evento (member.joined, purchase.completed…) o ping, la prueba |
version | La versión del formato de data de ese evento. Hoy, 1 en todos |
occurredAt | Cuándo ocurrió el hecho en Memberfy (ISO 8601, UTC), no cuándo salió la entrega |
community | La comunidad: id y slug |
data | Lo que ocurrió. Cambia por evento; ver Eventos de webhook |
Los importes vienen en céntimos, como { "amount": 9990, "currency": "BRL" }, siempre lo que pagó la persona. La persona implicada viene en data.member, con id, name, email, phone (en formato E.164, como +5541999998888, o null) y phoneCountry (el país del teléfono en ISO 3166-1 alfa-2, como BR, o null).
Los eventos
Son 97 eventos en 27 familias, más el ping:
| Familia | Eventos |
|---|---|
member | member.joined (también de quien se importa desde una hoja de cálculo, con 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 (con las respuestas de la inscripción en 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 (sin el texto del mensaje) |
badge | badge.awarded, badge.revoked |
| (prueba) | ping, enviado por el botón Enviar prueba y al reactivar, con data: { "message": "…" } |
El webhook se suscribe a eventos uno a uno o a la familia entera (member.*); quien marca la familia recibe también los eventos que entren en ella después. Los campos de cada uno están en Eventos de webhook.
Una invitación aún no aceptada no genera member.joined.
Niveles y puntos
member.level_changed sale cuando cambia el nivel de la persona:
{
"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(subió, al instante) odown(bajó).cause:points(los puntos cambiaron el nivel) oactivity(los requisitos de la semana: cumplidos, al subir, o no cumplidos la semana anterior, en la bajada del lunes a las 00:00 en la zona horaria de la comunidad).fromyto: el nivel antes y después, conid,level(el número, 1 es el primero) yname. Elidvienenullmientras la comunidad usa los niveles predeterminados sin haberlos abierto nunca.- Saltar varios niveles a la vez genera un evento, con el nivel final en
to.
member.points_changed sale cuando cambia el total de puntos de la persona en la comunidad: from, to, delta y source (action, una acción de la persona; reversal, puntos devueltos porque la acción se deshizo; team, un ajuste del equipo; workflow, un ajuste de un workflow).
Ninguno de los dos sale en un recálculo de puntos, en una importación, cuando la comunidad cambia los niveles ni con la gamificación desactivada.
Lo que nunca va
Ningún evento lleva comisiones, importe neto, datos del procesador de pago o de la tarjeta, documento fiscal del registro, dirección, fecha de nacimiento, campos personalizados, contraseña, token o secreto, IP, ni el texto de publicaciones, comentarios y tickets. Las respuestas de un formulario (form.submitted) y de las preguntas de la inscripción de un evento (event.registered) van como la persona las rellenó, incluido el campo Documento. El nombre, el correo y el teléfono de la persona (si lo registró) van siempre: por eso la pantalla avisa, al crear el webhook, de que los datos personales van al destino.
Verificar la firma
Toda entrega va firmada con el estándar Standard Webhooks, con el secreto del webhook (empieza por whsec_ y aparece una sola vez, al crearlo o al rotarlo). Tres cabeceras:
| Cabecera | Qué es |
|---|---|
webhook-id | El id del evento (el mismo id del cuerpo) |
webhook-timestamp | Cuándo salió el intento, en segundos desde 1970 |
webhook-signature | v1, seguido del HMAC-SHA256, en base64, de {webhook-id}.{webhook-timestamp}.{cuerpo} |
Verifica siempre antes de confiar en el contenido, con el cuerpo exactamente como llegó (sin parsearlo antes), y rechaza un webhook-timestamp con más de 5 minutos de diferencia con tu reloj. La librería standardwebhooks hace las dos cosas. En Node, con Express:
import express from 'express';
import { Webhook } from 'standardwebhooks';
const app = express();
const webhook = new Webhook(process.env.MEMBERFY_WEBHOOK_SECRET); // whsec_…
// El cuerpo crudo: la firma es sobre los bytes que llegaron.
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); // responde enseguida y procesa después
await queue.add(event); // p. ej.: tu cola de trabajo
});
app.listen(3000);
verify comprueba la firma y la hora y devuelve el sobre ya leído. Hay librerías del mismo estándar para Python, Go, Ruby, PHP, Java y otros lenguajes.
Al rotar el secreto, el nuevo vale en el acto y el antiguo deja de valer: actualiza tu lado enseguida.
Responder y procesar
- Responde con cualquier
2xxen un máximo de 10 segundos. Pasado ese tiempo, el intento cuenta como fallo. - Procesa después de responder (una cola, un job): una integración lenta no puede retener la respuesta.
- Deduplica por el
webhook-id. El mismo evento puede llegar más de una vez (una respuesta que se perdió en la red, un reenvío desde la pantalla): guarda los ids ya procesados e ignora el repetido. - El orden no está garantizado. Un
subscription.renewedpuede llegar antes que elsubscription.createdque estaba en reintento. Usa eloccurredAtpara ordenar y, cuando necesites el estado actual, pregunta a la API. - Las redirecciones no se siguen: un
301o302cuenta como fallo. Registra la URL final.
Reintentos
Sin un 2xx, Memberfy lo intenta de nuevo. Son hasta 8 intentos por entrega: el primero en el acto y los demás después de 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h, unas 45 horas en total. Agotados los 8, la entrega queda como Falló en el historial, y se puede reenviar desde la pantalla (un ciclo nuevo, con el mismo webhook-id).
| La respuesta | Qué pasa |
|---|---|
2xx | Entregada |
410 Gone | El webhook se desactiva en el acto, sin más intentos, y el propietario y los administradores reciben un correo |
429 | Lo intenta de nuevo después del Retry-After (en segundos o como fecha), si lo hay; no cuenta como fallo |
| Cualquier otro estado, tiempo agotado o error de red | Fallo; sigue el calendario de arriba |
Desactivación automática
Si el destino pasa 72 horas sin ninguna entrega con éxito (con al menos 5 fallos en ese periodo), el webhook se desactiva (Desactivado por fallos), los intentos pendientes se cancelan y el propietario y los administradores reciben un correo. Los eventos que ocurran mientras esté desactivado no se envían después.
Para volver, usa Reactivar en la pantalla: Memberfy envía antes un ping, y el webhook solo vuelve si el destino responde 2xx.
Dirección y red
- Solo
https, en el puerto 443, en una dirección pública. Las direcciones internas, de red privada o de loopback se rechazan. - Sin redirección y con un límite de 10 segundos por intento. De la respuesta, Memberfy guarda solo el comienzo (1 KB), para el historial.
- Si tu firewall solo acepta direcciones conocidas, marca Enviar desde IPs fijas en el webhook y permite las IPs fijas de Memberfy.
Límites
| Límite | Valor |
|---|---|
| Webhooks por comunidad | 10 |
| Crear, enviar prueba, reactivar, reenviar y cambiar la URL | 10 por minuto por comunidad, sumadas; por encima, 429 con Retry-After |
| Historial de entregas y de cambios | 30 días |
Cada entrega con éxito de un evento cuenta en Webhooks entregados, en el bloque Uso de Integraciones y APIs. El ping no cuenta.
Gestionar por API
La pantalla usa la API de webhooks en https://developers.memberfy.net (/api/webhooks: listar, crear, editar la URL, la descripción, los eventos y las IPs fijas, enviar prueba, rotar, desactivar, reactivar, eliminar, entregas, reenvío e historial). Acepta solo el token de inicio de sesión (Authorization: Bearer) con el X-CommunityId, de propietario o administrador; una clave de API no entra. La especificación está en developers.memberfy.net/docs.json. El catálogo de eventos es público: GET /api/integrations/events.
Editar un webhook
PATCH /api/webhooks/{id} solo con lo que cambia; un campo ausente queda 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 | Observación |
|---|---|---|
url | texto (hasta 2048) | Misma comprobación que en la creación (https, puerto 443, dirección pública); cuenta para el límite de 10 por minuto por comunidad |
description | texto (hasta 200) o null | |
events | lista (1 a 50) | Tipos o familias enteras (member.*) |
fixedIps | booleano | Activa o desactiva Enviar desde IPs fijas |
- La configuración nueva vale para los eventos que ocurran después. Las entregas pendientes, en reintento o reenviadas después siguen como se crearon: misma URL, mismo uso de IP fija y mismo payload. La firma usa siempre el secreto actual.
- Cambiar la
urlpone a cerofailureCount. - Editar no reactiva: un webhook
DISABLEDoDISABLED_FAILINGsigue desactivado. Reactivar esPOST /api/webhooks/{id}/enable, que envía la prueba y solo reactiva con respuesta 2xx. - Cada edición que cambia algo se convierte en una línea
updateddel historial, con el antes y el después de cada campo enchanges(URLs enmascaradas). Una edición sin cambios no genera línea. - Respuestas:
200con el webhook,404,422(campo inválido, enerrors[].param) y429.
Integraciones listas
Las integraciones listas (Discord, Slack, Telegram, RD Station, HubSpot) usan la misma API, en /api/apps: GET /api/apps (catálogo, con la instalación de cada app), PUT /api/apps/{slug}/installation (activa o guarda la configuración y los eventos), POST …/installation/test, DELETE …/installation (desactiva; con ?remove=true, elimina y borra la configuración y las entregas), POST /api/apps/telegram/chats (Encontrar mi grupo) y las entregas en …/installation/deliveries, con reenvío. Las mismas reglas de acceso: token de inicio de sesión de propietario o administrador, nunca clave de API. Los campos secretos vuelven enmascarados; en un PUT, un campo secreto ausente mantiene el valor guardado.