# Webhooks

> Für Integratoren. Wie Memberfy dein System benachrichtigt, wenn in der Community etwas passiert, das Format des Umschlags, die Standard-Webhooks-Signatur und wie du sie in Node prüfst, Deduplizierung, Reihenfolge, Wiederholungen, automatisches Ausschalten und die Limits.

Ein Webhook ist Memberfy, das dein System aufruft: Wenn jemand beitritt, kauft, abonniert, sich für eine Veranstaltung anmeldet oder einen Kurs abschließt, sendet Memberfy sofort einen signierten `POST` an die URL, die du hinterlegt hast. Es ist der umgekehrte Weg der [API](/api/introducao), bei der dein System fragt.

Den Webhook legt der Eigentümer oder ein Administrator der Community an, unter **Einstellungen › Integrationen und APIs**, Tab **Webhooks**. Siehe [Integrationen & APIs › Webhooks](/configuracoes/integracoes-e-apis#webhooks). Diese Anleitung ist für alle, die den Code schreiben, der ihn empfängt.

## Was ankommt

Ein `POST` pro Ereignis und pro Webhook, mit `Content-Type: application/json; charset=utf-8` und `User-Agent: Memberfy-Webhooks/1`. Der Inhalt ist immer derselbe Umschlag:

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

| Feld | Was es ist |
|---|---|
| `id` | Die Kennung des Ereignisses. Sie ist dieselbe wie im Header `webhook-id`, bei allen Versuchen und beim erneuten Senden |
| `type` | Das Ereignis (`member.joined`, `purchase.completed`…) oder `ping`, der Test |
| `version` | Die Version des `data`-Formats dieses Ereignisses. Heute überall `1` |
| `occurredAt` | Wann das Ereignis bei Memberfy passiert ist (ISO 8601, UTC), nicht wann die Zustellung losging |
| `community` | Die Community: `id` und `slug` |
| `data` | Was passiert ist. Unterscheidet sich je Ereignis; siehe [Webhook-Ereignisse](/api/eventos-de-webhook) |

Beträge kommen in Cent, etwa `{ "amount": 9990, "currency": "BRL" }`, immer das, was die Person bezahlt hat. Die beteiligte Person steht in `data.member`, mit `id`, `name`, `email`, `phone` (im Format E.164, etwa `+5541999998888`, oder `null`) und `phoneCountry` (das Land der Telefonnummer nach ISO 3166-1 Alpha-2, etwa `BR`, oder `null`).

## Die Ereignisse

Es sind 97 Ereignisse in 27 Familien, dazu der `ping`:

| Familie | Ereignisse |
|---|---|
| `member` | `member.joined` (auch für per Tabelle importierte Personen, mit `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` (mit den Antworten zur Anmeldung 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` (ohne den Text der Nachricht) |
| `badge` | `badge.awarded`, `badge.revoked` |
| (Test) | `ping`, gesendet über den Button **Test senden** und beim Wiedereinschalten, mit `data: { "message": "…" }` |

Ein Webhook abonniert Ereignisse einzeln oder die ganze Familie (`member.*`); wer die Familie ankreuzt, bekommt auch Ereignisse, die später dazukommen. Die Felder jedes Ereignisses stehen unter [Webhook-Ereignisse](/api/eventos-de-webhook).

Eine noch nicht angenommene Einladung erzeugt kein `member.joined`.

### Level und Punkte

`member.level_changed` geht raus, wenn sich das [Level](/membros/gamificacao#level) der Person ändert:

```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` (aufgestiegen, sofort) oder `down` (abgestiegen).
- `cause`: `points` (die Punkte haben das Level geändert) oder `activity` (die Anforderungen der Woche: erfüllt, beim Aufstieg, oder in der Vorwoche nicht erfüllt, beim Abstieg am Montag um 00:00 in der Zeitzone der Community).
- `from` und `to`: das Level vorher und nachher, mit `id`, `level` (die Nummer, 1 ist das erste) und `name`. Die `id` ist `null`, solange die Community die Standard-Level nutzt und sie nie geöffnet hat.
- Mehrere Level auf einmal erzeugen **ein** Ereignis, mit dem Endlevel in `to`.

`member.points_changed` geht raus, wenn sich die Punktesumme der Person in der Community ändert: `from`, `to`, `delta` und `source` (`action`, eine Aktion der Person; `reversal`, zurückgenommene Punkte, weil die Aktion rückgängig gemacht wurde; `team`, eine Anpassung durch das Team; `workflow`, eine Anpassung durch einen Workflow).

`space.access_granted` geht raus, wenn eine Person Zugriff auf einen Bereich erhält, mit `source`: `purchase` (ein Kauf), `plan` (ein Abonnement), `team` (das Team) oder `badge` (die Person hat ein Badge erhalten, das den Bereich öffnet; ein Abschnitt, der sich öffnet, zählt jeden seiner Bereiche). Es geht nie raus, wenn Zugriff entzogen wird.

Keines der beiden geht bei einer Neuberechnung der Punkte, einem Import, einer Änderung der Level durch die Community oder bei ausgeschalteter Gamification raus.

### Was nie mitgeschickt wird

Kein Ereignis enthält Gebühren, Nettobeträge, Daten des Zahlungsanbieters oder der Karte, Steuernummern aus dem Konto, Adresse, Geburtsdatum, benutzerdefinierte Felder, Passwörter, Tokens oder Secrets, IP oder den Text von Beiträgen, Kommentaren und Tickets. Die Antworten auf ein Formular (`form.submitted`) und auf die Anmeldefragen einer Veranstaltung (`event.registered`) gehen so mit, wie die Person sie ausgefüllt hat, einschließlich des Felds **Ausweis**. Name, E-Mail und Telefonnummer der Person (wenn sie eine hinterlegt hat) sind immer dabei: Deshalb weist die Oberfläche beim Erstellen des Webhooks darauf hin, dass personenbezogene Daten an das Ziel gehen.

## Die Signatur prüfen

Jede Zustellung ist nach [Standard Webhooks](https://www.standardwebhooks.com) signiert, mit dem Secret des Webhooks (beginnt mit `whsec_` und wird nur einmal angezeigt, beim Erstellen oder Rotieren). Drei Header:

| Header | Was es ist |
|---|---|
| `webhook-id` | Die ID des Ereignisses (dieselbe `id` wie im Inhalt) |
| `webhook-timestamp` | Wann der Versuch losging, in Sekunden seit 1970 |
| `webhook-signature` | `v1,` gefolgt vom HMAC-SHA256 in Base64 von `{webhook-id}.{webhook-timestamp}.{Inhalt}` |

Prüfe **immer**, bevor du dem Inhalt vertraust, mit dem Inhalt genau so, wie er angekommen ist (ohne ihn vorher zu parsen), und lehne einen `webhook-timestamp` ab, der mehr als 5 Minuten von deiner Uhr abweicht. Die Bibliothek [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) erledigt beides. In Node mit Express:

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

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

// Der rohe Inhalt: Die Signatur gilt für die angekommenen Bytes.
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); // deduplizieren
  res.sendStatus(200); // sofort antworten, später verarbeiten
  await queue.add(event); // z. B. deine Job-Warteschlange
});

app.listen(3000);
```

`verify` prüft Signatur und Zeit und liefert den bereits gelesenen Umschlag. Bibliotheken für denselben Standard gibt es für Python, Go, Ruby, PHP, Java und weitere Sprachen.

Beim **Rotieren des Secrets** gilt das neue sofort und das alte nicht mehr: Aktualisiere deine Seite direkt danach.

## Antworten und verarbeiten

- Antworte innerhalb von **10 Sekunden** mit einem beliebigen `2xx`. Danach zählt der Versuch als Fehler.
- Verarbeite nach der Antwort (eine Warteschlange, ein Job): Eine langsame Integration darf die Antwort nicht aufhalten.
- **Dedupliziere über die `webhook-id`.** Dasselbe Ereignis kann mehrmals ankommen (eine im Netz verlorene Antwort, ein erneutes Senden über die Oberfläche): Speichere die schon verarbeiteten IDs und ignoriere Wiederholungen.
- **Die Reihenfolge ist nicht garantiert.** Ein `subscription.renewed` kann vor dem `subscription.created` ankommen, das gerade wiederholt wurde. Sortiere nach `occurredAt` und frag die [API](/api/introducao), wenn du den aktuellen Stand brauchst.
- Weiterleitungen werden nicht verfolgt: Ein `301` oder `302` zählt als Fehler. Hinterlege die endgültige URL.

## Wiederholungen

Ohne `2xx` versucht Memberfy es erneut. Es sind bis zu **8 Versuche** pro Zustellung: der erste sofort, die weiteren nach **1 Min., 5 Min., 30 Min., 2 Std., 6 Std., 12 Std. und 24 Std.**, insgesamt etwa 45 Stunden. Nach allen 8 bleibt die Zustellung im Verlauf als **Fehlgeschlagen**, und sie lässt sich über die Oberfläche **erneut senden** (ein neuer Zyklus, mit derselben `webhook-id`).

| Die Antwort | Was passiert |
|---|---|
| `2xx` | Zugestellt |
| `410 Gone` | Der Webhook wird **sofort ausgeschaltet**, ohne weitere Versuche, und Eigentümer und Administratoren erhalten eine E-Mail |
| `429` | Neuer Versuch nach dem `Retry-After` (in Sekunden oder als Datum), falls vorhanden; **zählt nicht als Fehler** |
| Jeder andere Status, Zeitüberschreitung oder Netzwerkfehler | Fehler; folgt dem Zeitplan oben |

### Automatisches Ausschalten

Bleibt das Ziel **72 Stunden ohne erfolgreiche Zustellung** (mit mindestens 5 Fehlern in dieser Zeit), wird der Webhook ausgeschaltet (**Wegen Fehlern ausgeschaltet**), ausstehende Versuche werden abgebrochen, und Eigentümer und Administratoren erhalten eine E-Mail. Ereignisse, die währenddessen passieren, werden später nicht gesendet.

Zum Zurückholen nutze **Wieder einschalten** in der Oberfläche: Memberfy sendet zuerst einen `ping`, und der Webhook kommt nur zurück, wenn das Ziel mit `2xx` antwortet.

## Adresse und Netzwerk

- Nur **`https`**, auf Port **443**, an einer öffentlichen Adresse. Interne Adressen, private Netze und Loopback werden abgelehnt.
- Keine Weiterleitung und ein Limit von **10 Sekunden** pro Versuch. Von der Antwort speichert Memberfy nur den Anfang (1 KB) für den Verlauf.
- Akzeptiert deine Firewall nur bekannte Adressen, kreuze beim Webhook **Von festen IPs senden** an und gib die [festen IPs von Memberfy](/api/ips-fixos) frei.

## Limits

| Limit | Wert |
|---|---|
| Webhooks pro Community | 10 |
| Erstellen, Test senden, wieder einschalten, erneut senden und URL ändern | 10 pro Minute pro Community, zusammen; darüber `429` mit `Retry-After` |
| Verlauf der Zustellungen und Änderungen | 30 Tage |

Jede erfolgreiche Zustellung eines Ereignisses zählt bei **Zugestellte Webhooks** im Block **Nutzung** von [Integrationen & APIs](/configuracoes/integracoes-e-apis#der-block-nutzung). Der `ping` zählt nicht.

## Per API verwalten

Die Oberfläche nutzt die Webhook-API unter `https://developers.memberfy.net` (`/api/webhooks`: auflisten, erstellen, URL, Beschreibung, Ereignisse und feste IPs bearbeiten, Test senden, rotieren, ausschalten, wieder einschalten, löschen, Zustellungen, erneutes Senden und Verlauf). Sie akzeptiert nur das Anmelde-Token (`Authorization: Bearer`) mit der `X-CommunityId`, von Eigentümer oder Administrator; **ein API-Schlüssel kommt nicht hinein**. Die Spezifikation liegt unter [developers.memberfy.net/docs.json](https://developers.memberfy.net/docs.json). Der Ereigniskatalog ist öffentlich: [`GET /api/integrations/events`](/api/referencia/integrations).

### Einen Webhook bearbeiten

`PATCH /api/webhooks/{id}` nur mit dem, was sich ändert; ein fehlendes Feld bleibt, wie es ist.

```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}'
```

| Feld | Typ | Hinweis |
|---|---|---|
| `url` | Text (bis 2048) | Dieselbe Prüfung wie beim Erstellen (`https`, Port 443, öffentliche Adresse); zählt zum Limit von 10 pro Minute und Community |
| `description` | Text (bis 200) oder `null` | |
| `events` | Liste (1 bis 50) | Typen oder ganze Familien (`member.*`) |
| `fixedIps` | Boolean | Schaltet **Von festen IPs senden** ein oder aus |

- Die neue Konfiguration gilt für **Ereignisse, die danach passieren**. Ausstehende Zustellungen, Wiederholungen und späteres erneutes Senden laufen so, wie sie erstellt wurden: dieselbe URL, dieselbe Nutzung fester IPs und derselbe Payload. Die Signatur nutzt immer das aktuelle Secret.
- Eine neue `url` setzt `failureCount` zurück.
- **Bearbeiten schaltet nicht wieder ein**: Ein Webhook `DISABLED` oder `DISABLED_FAILING` bleibt aus. Wieder einschalten ist `POST /api/webhooks/{id}/enable`, das den Test sendet und nur bei einer 2xx-Antwort einschaltet.
- Jede Bearbeitung, die etwas ändert, wird zu einer Zeile `updated` im Verlauf, mit Vorher und Nachher jedes Felds in `changes` (URLs maskiert). Eine Bearbeitung ohne Änderung erzeugt keine Zeile.
- Antworten: `200` mit dem Webhook, `404`, `422` (ungültiges Feld, in `errors[].param`) und `429`.

### Fertige Integrationen

Die [fertigen Integrationen](/integracoes/integracoes-prontas) (Discord, Slack, Telegram, RD Station, HubSpot) nutzen dieselbe API unter `/api/apps`: `GET /api/apps` (Katalog, mit der Installation jeder App), `PUT /api/apps/{slug}/installation` (schaltet ein oder speichert Konfiguration und Ereignisse), `POST …/installation/test`, `DELETE …/installation` (schaltet aus; mit `?remove=true` wird sie entfernt und Konfiguration und Zustellungen gelöscht), `POST /api/apps/telegram/chats` (*Meine Gruppe finden*) und die Zustellungen unter `…/installation/deliveries`, mit erneutem Senden. Dieselben Zugriffsregeln: Login-Token von Eigentümer oder Administrator, nie ein API-Schlüssel. Geheime Felder kommen maskiert zurück; bei einem `PUT` behält ein fehlendes geheimes Feld den gespeicherten Wert.

## Verwandte Artikel

- [Webhook-Ereignisse](/api/eventos-de-webhook)
- [Feste IPs](/api/ips-fixos)
- [Integrationen & APIs](/configuracoes/integracoes-e-apis#webhooks)
- [Authentifizierung](/api/autenticacao)
