# 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](/api/introducao), 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](/configuracoes/integracoes-e-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:

```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": "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](/api/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](/api/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](/membros/gamificacao#niveles) de la persona:

```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` (subió, al instante) o `down` (bajó).
- `cause`: `points` (los puntos cambiaron el nivel) o `activity` (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).
- `from` y `to`: el nivel antes y después, con `id`, `level` (el número, 1 es el primero) y `name`. El `id` viene `null` mientras 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).

`space.access_granted` sale cuando la persona gana acceso a un espacio, con `source`: `purchase` (una compra), `plan` (una suscripción), `team` (el equipo) o `badge` (la persona ganó una insignia que abre el espacio; una sección que se abre cuenta cada uno de sus espacios). Nunca sale cuando se quita el acceso.

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](https://www.standardwebhooks.com), 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`](https://www.npmjs.com/package/standardwebhooks) hace las dos cosas. En Node, con Express:

```js
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 `2xx` en 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.renewed` puede llegar antes que el `subscription.created` que estaba en reintento. Usa el `occurredAt` para ordenar y, cuando necesites el estado actual, pregunta a la [API](/api/introducao).
- Las redirecciones no se siguen: un `301` o `302` cuenta 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](/api/ips-fixos).

## 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](/configuracoes/integracoes-e-apis#el-bloque-uso). 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](https://developers.memberfy.net/docs.json). El catálogo de eventos es público: [`GET /api/integrations/events`](/api/referencia/integrations).

### Editar un webhook

`PATCH /api/webhooks/{id}` solo con lo que cambia; un campo ausente queda 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 | 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 `url` pone a cero `failureCount`.
- **Editar no reactiva**: un webhook `DISABLED` o `DISABLED_FAILING` sigue desactivado. Reactivar es `POST /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 `updated` del historial, con el antes y el después de cada campo en `changes` (URLs enmascaradas). Una edición sin cambios no genera línea.
- Respuestas: `200` con el webhook, `404`, `422` (campo inválido, en `errors[].param`) y `429`.

### Integraciones listas

Las [integraciones listas](/integracoes/integracoes-prontas) (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.

## Relacionados

- [Eventos de webhook](/api/eventos-de-webhook)
- [IPs fijas](/api/ips-fixos)
- [Integraciones y APIs](/configuracoes/integracoes-e-apis#webhooks)
- [Autenticación](/api/autenticacao)
