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, 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. 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:
{
"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": "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 |
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.
An invitation not yet accepted does not generate member.joined.
Levels and points
member.level_changed goes out when the person's level changes:
{
"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(moved up, at once) ordown(moved down).cause:points(the points moved the level) oractivity(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).fromandto: the level before and after, withid,level(the number, 1 is the first) andname. Theidisnullwhile 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, 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 library does both. In Node, with Express:
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
2xxwithin 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.renewedmay arrive before thesubscription.createdthat was being retried. UseoccurredAtto order and, when you need the current state, ask the API. - Redirects aren't followed: a
301or302counts 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
httpsonly, 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.
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. 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. The event catalog is public: GET /api/integrations/events.
Edit a webhook
PATCH /api/webhooks/{id} with only what changes; a missing field stays as it is.
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
urlresetsfailureCount. - Editing does not turn it back on: a
DISABLEDorDISABLED_FAILINGwebhook stays off. Turning it back on isPOST /api/webhooks/{id}/enable, which sends the test and only turns it on with a 2xx answer. - Each edit that changes something becomes an
updatedline in the history, with the before and after of each field inchanges(URLs masked). An edit without changes adds no line. - Responses:
200with the webhook,404,422(invalid field, inerrors[].param) and429.
Ready-made integrations
The ready-made integrations (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.