# Webhook

> Per chi integra. Come Memberfy avvisa il tuo sistema quando succede qualcosa nella community, il formato della busta, la firma Standard Webhooks e come verificarla in Node, deduplicazione, ordine, nuovi tentativi, disattivazione automatica e i limiti.

Un webhook è Memberfy che chiama il tuo sistema: quando qualcuno entra, acquista, si abbona, si iscrive a un evento o completa un corso, Memberfy invia subito un `POST` firmato all'URL che hai registrato. È il percorso inverso dell'[API](/api/introducao), in cui è il tuo sistema a chiedere.

Il webhook lo crea il proprietario o un amministratore della community, in **Impostazioni › Integrazioni e API**, scheda **Webhook**. Vedi [Integrazioni e API › Webhook](/configuracoes/integracoes-e-apis#webhook). Questa guida è per chi scrive il codice che lo riceve.

## Cosa arriva

Un `POST` per evento e per webhook, con `Content-Type: application/json; charset=utf-8` e `User-Agent: Memberfy-Webhooks/1`. Il corpo è sempre la stessa busta:

```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": "Piano Pro", "type": "SUBSCRIPTION" },
    "amount": { "amount": 9990, "currency": "BRL" }
  }
}
```

| Campo | Cos'è |
|---|---|
| `id` | L'identificativo dell'evento. È lo stesso dell'intestazione `webhook-id`, in tutti i tentativi e nel reinvio |
| `type` | L'evento (`member.joined`, `purchase.completed`…) o `ping`, il test |
| `version` | La versione del formato di `data` di quell'evento. Oggi, `1` per tutti |
| `occurredAt` | Quando il fatto è avvenuto in Memberfy (ISO 8601, UTC), non quando è partita la consegna |
| `community` | La community: `id` e `slug` |
| `data` | Cosa è successo. Cambia per evento; vedi [Eventi dei webhook](/api/eventos-de-webhook) |

Gli importi arrivano in centesimi, come `{ "amount": 9990, "currency": "BRL" }`, sempre quanto ha pagato la persona. La persona coinvolta arriva in `data.member`, con `id`, `name`, `email`, `phone` (in formato E.164, come `+5541999998888`, oppure `null`) e `phoneCountry` (il paese del telefono in ISO 3166-1 alfa-2, come `BR`, oppure `null`).

## Gli eventi

Sono 97 eventi in 27 famiglie, più il `ping`:

| Famiglia | Eventi |
|---|---|
| `member` | `member.joined` (anche per chi viene importato da un foglio di calcolo, 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 le risposte dell'iscrizione in `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` (senza il testo del messaggio) |
| `badge` | `badge.awarded`, `badge.revoked` |
| (test) | `ping`, inviato dal pulsante **Invia test** e alla riattivazione, con `data: { "message": "…" }` |

Il webhook si iscrive agli eventi uno per uno o all'intera famiglia (`member.*`); chi seleziona la famiglia riceve anche gli eventi che vi entreranno in seguito. I campi di ognuno sono in [Eventi dei webhook](/api/eventos-de-webhook).

Un invito non ancora accettato non genera `member.joined`.

### Livelli e punti

`member.level_changed` parte quando cambia il [livello](/membros/gamificacao#livelli) della 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` (salito, subito) o `down` (sceso).
- `cause`: `points` (i punti hanno cambiato il livello) o `activity` (i requisiti della settimana: soddisfatti, in salita, o non soddisfatti nella settimana precedente, nella discesa del lunedì alle 00:00 nel fuso orario della community).
- `from` e `to`: il livello prima e dopo, con `id`, `level` (il numero, 1 è il primo) e `name`. L'`id` è `null` finché la community usa i livelli predefiniti senza averli mai aperti.
- Saltare più livelli insieme genera **un** solo evento, con il livello finale in `to`.

`member.points_changed` parte quando cambia il totale dei punti della persona nella community: `from`, `to`, `delta` e `source` (`action`, un'azione della persona; `reversal`, punti restituiti perché l'azione è stata annullata; `team`, una correzione del team; `workflow`, una correzione di un workflow).

`space.access_granted` parte quando la persona ottiene l'accesso a uno spazio, con `source`: `purchase` (un acquisto), `plan` (un abbonamento), `team` (il team) o `badge` (la persona ha ottenuto un badge che apre lo spazio; una sezione che si apre conta ognuno dei suoi spazi). Non parte mai quando l'accesso viene tolto.

Nessuno dei due parte con un ricalcolo dei punti, un'importazione, quando la community cambia i livelli o con la gamification disattivata.

### Cosa non viene mai inviato

Nessun evento porta commissioni, importo netto, dati del processore di pagamento o della carta, codice fiscale dell'anagrafica, indirizzo, data di nascita, campi personalizzati, password, token o segreti, IP, né il testo di post, commenti e ticket. Le risposte a un modulo (`form.submitted`) e alle domande dell'iscrizione di un evento (`event.registered`) vanno come la persona le ha compilate, compreso il campo **Documento**. Nome, e-mail e telefono della persona (se l'ha registrato) vanno sempre: per questo la schermata avvisa, alla creazione del webhook, che i dati personali vanno alla destinazione.

## Verificare la firma

Ogni consegna è firmata secondo lo standard [Standard Webhooks](https://www.standardwebhooks.com), con il segreto del webhook (inizia con `whsec_` e appare una sola volta, alla creazione o alla rotazione). Tre intestazioni:

| Intestazione | Cos'è |
|---|---|
| `webhook-id` | L'id dell'evento (lo stesso `id` del corpo) |
| `webhook-timestamp` | Quando è partito il tentativo, in secondi dal 1970 |
| `webhook-signature` | `v1,` seguito dall'HMAC-SHA256, in base64, di `{webhook-id}.{webhook-timestamp}.{corpo}` |

Verifica **sempre** prima di fidarti del contenuto, con il corpo esattamente come è arrivato (senza farne prima il parse), e rifiuta un `webhook-timestamp` che differisca di più di 5 minuti dal tuo orologio. La libreria [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) fa entrambe le cose. In 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_…

// Il corpo grezzo: la firma riguarda i byte arrivati.
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); // deduplicare
  res.sendStatus(200); // rispondi subito ed elabora dopo
  await queue.add(event); // es.: la tua coda di lavoro
});

app.listen(3000);
```

`verify` controlla la firma e l'orario e restituisce la busta già letta. Esistono librerie dello stesso standard per Python, Go, Ruby, PHP, Java e altri linguaggi.

Quando si **ruota il segreto**, il nuovo vale subito e il vecchio smette di valere: aggiorna subito il tuo lato.

## Rispondere ed elaborare

- Rispondi con qualsiasi `2xx` entro **10 secondi**. Oltre, il tentativo conta come errore.
- Elabora dopo aver risposto (una coda, un job): un'integrazione lenta non può trattenere la risposta.
- **Deduplica tramite il `webhook-id`.** Lo stesso evento può arrivare più di una volta (una risposta persa in rete, un reinvio dalla schermata): salva gli id già elaborati e ignora il doppione.
- **L'ordine non è garantito.** Un `subscription.renewed` può arrivare prima del `subscription.created` che era in nuovo tentativo. Usa `occurredAt` per ordinare e, quando ti serve lo stato attuale, chiedi all'[API](/api/introducao).
- I reindirizzamenti non vengono seguiti: un `301` o `302` conta come errore. Registra l'URL finale.

## Nuovi tentativi

Senza un `2xx`, Memberfy riprova. Sono fino a **8 tentativi** per consegna: il primo subito e gli altri dopo **1 min, 5 min, 30 min, 2 h, 6 h, 12 h e 24 h**, circa 45 ore in tutto. Esauriti gli 8, la consegna resta **Non riuscita** nella cronologia, e si può **reinviare** dalla schermata (un nuovo ciclo, con lo stesso `webhook-id`).

| La risposta | Cosa succede |
|---|---|
| `2xx` | Consegnata |
| `410 Gone` | Il webhook viene **disattivato subito**, senza altri tentativi, e proprietario e amministratori ricevono un'e-mail |
| `429` | Riprova dopo il `Retry-After` (in secondi o come data), se presente; **non conta come errore** |
| Qualsiasi altro stato, tempo scaduto o errore di rete | Errore; segue il calendario sopra |

### Disattivazione automatica

Se la destinazione resta **72 ore senza nessuna consegna riuscita** (con almeno 5 errori nel periodo), il webhook viene disattivato (**Disattivato per errori**), i tentativi in attesa vengono annullati e proprietario e amministratori ricevono un'e-mail. Gli eventi che avvengono mentre è disattivato non vengono inviati dopo.

Per tornare, usa **Riattiva** nella schermata: Memberfy invia prima un `ping`, e il webhook torna attivo solo se la destinazione risponde `2xx`.

## Indirizzo e rete

- Solo **`https`**, sulla porta **443**, su un indirizzo pubblico. Gli indirizzi interni, di rete privata o di loopback vengono rifiutati.
- Nessun reindirizzamento e un limite di **10 secondi** per tentativo. Della risposta, Memberfy conserva solo l'inizio (1 KB), per la cronologia.
- Se il tuo firewall accetta solo indirizzi noti, seleziona **Invia da IP fissi** sul webhook e consenti gli [IP fissi di Memberfy](/api/ips-fixos).

## Limiti

| Limite | Valore |
|---|---|
| Webhook per community | 10 |
| Creare, inviare test, riattivare, reinviare e cambiare l'URL | 10 al minuto per community, sommati; oltre, `429` con `Retry-After` |
| Cronologia delle consegne e delle modifiche | 30 giorni |

Ogni consegna riuscita di un evento conta in **Webhook consegnati**, nel blocco **Utilizzo** di [Integrazioni e API](/configuracoes/integracoes-e-apis#il-blocco-utilizzo). Il `ping` non conta.

## Gestire tramite API

La schermata usa l'API dei webhook su `https://developers.memberfy.net` (`/api/webhooks`: elencare, creare, modificare URL, descrizione, eventi e IP fissi, inviare test, ruotare, disattivare, riattivare, eliminare, consegne, reinvio e cronologia). Accetta solo il token di login (`Authorization: Bearer`) con l'`X-CommunityId`, di proprietario o amministratore; **una chiave API non entra**. La specifica è su [developers.memberfy.net/docs.json](https://developers.memberfy.net/docs.json). Il catalogo degli eventi è pubblico: [`GET /api/integrations/events`](/api/referencia/integrations).

### Modificare un webhook

`PATCH /api/webhooks/{id}` solo con ciò che cambia; un campo assente resta com'è.

```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 | Nota |
|---|---|---|
| `url` | testo (fino a 2048) | Stesso controllo della creazione (`https`, porta 443, indirizzo pubblico); conta nel limite di 10 al minuto per community |
| `description` | testo (fino a 200) o `null` | |
| `events` | lista (da 1 a 50) | Tipi o famiglie intere (`member.*`) |
| `fixedIps` | booleano | Attiva o disattiva **Invia da IP fissi** |

- La nuova configurazione vale per gli **eventi che accadono dopo**. Le consegne in sospeso, in nuovo tentativo o reinviate dopo restano come sono state create: stesso URL, stesso uso dell'IP fisso e stesso payload. La firma usa sempre il segreto attuale.
- Cambiare l'`url` azzera `failureCount`.
- **Modificare non riattiva**: un webhook `DISABLED` o `DISABLED_FAILING` resta disattivato. Riattivare è `POST /api/webhooks/{id}/enable`, che invia il test e riattiva solo con risposta 2xx.
- Ogni modifica che cambia qualcosa diventa una riga `updated` nella cronologia, con il prima e il dopo di ogni campo in `changes` (URL mascherati). Una modifica senza cambiamenti non genera righe.
- Risposte: `200` con il webhook, `404`, `422` (campo non valido, in `errors[].param`) e `429`.

### Integrazioni pronte

Le [integrazioni pronte](/integracoes/integracoes-prontas) (Discord, Slack, Telegram, RD Station, HubSpot) usano la stessa API, in `/api/apps`: `GET /api/apps` (catalogo, con l'installazione di ogni app), `PUT /api/apps/{slug}/installation` (attiva o salva la configurazione e gli eventi), `POST …/installation/test`, `DELETE …/installation` (disattiva; con `?remove=true`, rimuove e cancella la configurazione e le consegne), `POST /api/apps/telegram/chats` (*Trova il mio gruppo*) e le consegne in `…/installation/deliveries`, con reinvio. Stesse regole di accesso: token di accesso di proprietario o admin, mai una chiave API. I campi segreti tornano mascherati; in un `PUT`, un campo segreto assente mantiene il valore salvato.

## Correlati

- [Eventi dei webhook](/api/eventos-de-webhook)
- [IP fissi](/api/ips-fixos)
- [Integrazioni e API](/configuracoes/integracoes-e-apis#webhook)
- [Autenticazione](/api/autenticacao)
