# Webhooks

> For integrators. How Memberfy notifies your system when something happens in the community, the envelope format, the Standard Webhooks signature and how to verify it in Node, deduplication, ordering, retries, automatic shutdown and the limits.

A webhook is Memberfy calling your system: when someone joins, buys, subscribes, registers for an event or completes a course, Memberfy sends a `POST` to the URL you registered, signed, right away. It's the reverse of the [API](/api/introducao), where your system is the one asking.

The webhook is created by the community's owner or an admin, in **Settings › Integrations and APIs**, **Webhooks** tab. See [Integrations & APIs › Webhooks](/configuracoes/integracoes-e-apis#webhooks). This guide is for whoever writes the code that receives it.

## What arrives

One `POST` per event and per webhook, with `Content-Type: application/json; charset=utf-8` and `User-Agent: Memberfy-Webhooks/1`. The body is always the same envelope:

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

| Field | What it is |
|---|---|
| `id` | The event's identifier. It's the same as the `webhook-id` header, on every attempt and on a resend |
| `type` | The event (`member.joined`, `purchase.completed`…) or `ping`, the test |
| `version` | The version of that event's `data` format. Today, `1` for all |
| `occurredAt` | When the fact happened in Memberfy (ISO 8601, UTC), not when the delivery went out |
| `community` | The community: `id` and `slug` |
| `data` | What happened. It changes per event; see [Webhook events](/api/eventos-de-webhook) |

Money comes in cents, as `{ "amount": 9990, "currency": "BRL" }`, always what the person paid. The person involved comes in `data.member`, with `id`, `name`, `email`, `phone` (in E.164 format, like `+5541999998888`, or `null`) and `phoneCountry` (the phone's country in ISO 3166-1 alpha-2, like `BR`, or `null`).

## The events

There are 97 events in 27 families, plus the `ping`:

| Family | Events |
|---|---|
| `member` | `member.joined` (including people imported from a spreadsheet, with `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` (with the registration answers 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` (without the message text) |
| `badge` | `badge.awarded`, `badge.revoked` |
| (test) | `ping`, sent by the **Send test** button and when turning a webhook back on, with `data: { "message": "…" }` |

A webhook subscribes to events one by one or to a whole family (`member.*`); whoever checks the family also gets the events added to it later. Each event's fields are in [Webhook events](/api/eventos-de-webhook).

An invitation not yet accepted does not generate `member.joined`.

### Levels and points

`member.level_changed` goes out when the person's [level](/membros/gamificacao#levels) changes:

```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` (moved up, at once) or `down` (moved down).
- `cause`: `points` (the points moved the level) or `activity` (the week's requirements: met, when moving up, or not met in the previous week, on the Monday 00:00 drop in the community's time zone).
- `from` and `to`: the level before and after, with `id`, `level` (the number, 1 is the first) and `name`. The `id` is `null` while the community uses the default levels and has never opened them.
- Skipping several levels at once generates **one** event, with the final level in `to`.

`member.points_changed` goes out when the person's points total in the community changes: `from`, `to`, `delta` and `source` (`action`, an action of the person; `reversal`, points taken back because the action was undone; `team`, a team adjustment; `workflow`, a workflow adjustment).

Neither goes out on a points recalculation, an import, when the community changes its levels or with gamification off.

### What never goes

No event carries fees, net amounts, payment processor or card data, account tax IDs, address, date of birth, custom fields, passwords, tokens or secrets, IP, or the text of posts, comments and tickets. The answers to a form (`form.submitted`) and to an event's registration questions (`event.registered`) go as the person filled them in, the **Document** field included. The person's name, email and phone (when they added one) always go: that's why the screen warns, when creating the webhook, that personal data goes to the destination.

## Verify the signature

Every delivery is signed following [Standard Webhooks](https://www.standardwebhooks.com), with the webhook's secret (it starts with `whsec_` and is shown only once, on creation or when rotating). Three headers:

| Header | What it is |
|---|---|
| `webhook-id` | The event id (the same `id` as the body) |
| `webhook-timestamp` | When the attempt went out, in seconds since 1970 |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}` |

**Always** verify before trusting the content, with the body exactly as it arrived (don't parse it first), and reject a `webhook-timestamp` more than 5 minutes off your clock. The [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) library does both. In Node, with Express:

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

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

// The raw body: the signature covers the bytes that arrived.
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); // deduplicate
  res.sendStatus(200); // answer right away and process later
  await queue.add(event); // e.g. your job queue
});

app.listen(3000);
```

`verify` checks the signature and the time and returns the envelope already parsed. Libraries for the same standard exist for Python, Go, Ruby, PHP, Java and other languages.

When the **secret is rotated**, the new one works right away and the old one stops working: update your side right after.

## Respond and process

- Answer with any `2xx` within **10 seconds**. Past that, the attempt counts as a failure.
- Process after answering (a queue, a job): a slow integration can't hold up the answer.
- **Deduplicate by `webhook-id`.** The same event can arrive more than once (an answer lost on the network, a resend from the screen): store the ids already processed and ignore the repeat.
- **Order isn't guaranteed.** A `subscription.renewed` may arrive before the `subscription.created` that was being retried. Use `occurredAt` to order and, when you need the current state, ask the [API](/api/introducao).
- Redirects aren't followed: a `301` or `302` counts as a failure. Register the final URL.

## Retries

Without a `2xx`, Memberfy tries again. There are up to **8 attempts** per delivery: the first right away and the others after **1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h**, about 45 hours in total. After all 8, the delivery stays as **Failed** in the history, and it can be **resent** from the screen (a new cycle, with the same `webhook-id`).

| The answer | What happens |
|---|---|
| `2xx` | Delivered |
| `410 Gone` | The webhook is **disabled right away**, with no more attempts, and the owner and admins get an email |
| `429` | Tries again after the `Retry-After` (in seconds or as a date), if present; **it doesn't count as a failure** |
| Any other status, timeout or network error | Failure; follows the schedule above |

### Automatic shutdown

If the destination goes **72 hours without any successful delivery** (with at least 5 failures in that period), the webhook is disabled (**Disabled after failures**), pending attempts are canceled and the owner and admins get an email. Events that happen while it's disabled aren't sent later.

To bring it back, use **Turn back on** on the screen: Memberfy sends a `ping` first, and the webhook only comes back if the destination answers `2xx`.

## Address and network

- **`https`** only, on port **443**, at a public address. Internal, private-network or loopback addresses are refused.
- No redirects, and a **10-second** limit per attempt. From the answer, Memberfy keeps only the beginning (1 KB), for the history.
- If your firewall only accepts known addresses, check **Send from fixed IPs** on the webhook and allow [Memberfy's fixed IPs](/api/ips-fixos).

## Limits

| Limit | Value |
|---|---|
| Webhooks per community | 10 |
| Create, send test, turn back on, resend and change the URL | 10 per minute per community, combined; above that, `429` with `Retry-After` |
| Delivery and change history | 30 days |

Each successful delivery of an event counts in **Webhooks delivered**, in the **Usage** block of [Integrations & APIs](/configuracoes/integracoes-e-apis#the-usage-block). The `ping` doesn't count.

## Manage through the API

The screen uses the webhooks API at `https://developers.memberfy.net` (`/api/webhooks`: list, create, edit the URL, description, events and fixed IPs, send test, rotate, disable, turn back on, delete, deliveries, resend and history). It only accepts the sign-in token (`Authorization: Bearer`) with the `X-CommunityId`, from an owner or admin; **an API key doesn't get in**. The specification is at [developers.memberfy.net/docs.json](https://developers.memberfy.net/docs.json). The event catalog is public: [`GET /api/integrations/events`](/api/referencia/integrations).

### Edit a webhook

`PATCH /api/webhooks/{id}` with only what changes; a missing field stays as it is.

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

| Field | Type | Note |
|---|---|---|
| `url` | text (up to 2048) | Same check as on creation (`https`, port 443, public address); counts toward the limit of 10 per minute per community |
| `description` | text (up to 200) or `null` | |
| `events` | list (1 to 50) | Types or whole families (`member.*`) |
| `fixedIps` | boolean | Turns **Send from fixed IPs** on or off |

- The new settings apply to **events that happen afterwards**. Pending deliveries, retries and later resends go as they were created: same URL, same fixed-IP use and same payload. The signature always uses the current secret.
- Changing the `url` resets `failureCount`.
- **Editing does not turn it back on**: a `DISABLED` or `DISABLED_FAILING` webhook stays off. Turning it back on is `POST /api/webhooks/{id}/enable`, which sends the test and only turns it on with a 2xx answer.
- Each edit that changes something becomes an `updated` line in the history, with the before and after of each field in `changes` (URLs masked). An edit without changes adds no line.
- Responses: `200` with the webhook, `404`, `422` (invalid field, in `errors[].param`) and `429`.

### Ready-made integrations

The [ready-made integrations](/integracoes/integracoes-prontas) (Discord, Slack, Telegram, RD Station, HubSpot) use the same API, under `/api/apps`: `GET /api/apps` (catalog, with each app's installation), `PUT /api/apps/{slug}/installation` (turns on or saves the settings and events), `POST …/installation/test`, `DELETE …/installation` (turns off; with `?remove=true`, removes it and deletes the settings and deliveries), `POST /api/apps/telegram/chats` (*Find my group*) and the deliveries under `…/installation/deliveries`, with resend. Same access rules: login token of an owner or admin, never an API key. Secret fields come back masked; on a `PUT`, a missing secret field keeps the saved value.

## Related

- [Webhook events](/api/eventos-de-webhook)
- [Fixed IPs](/api/ips-fixos)
- [Integrations & APIs](/configuracoes/integracoes-e-apis#webhooks)
- [Authentication](/api/autenticacao)
