Skip to content

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" }
  }
}
FieldWhat it is
idThe event's identifier. It's the same as the webhook-id header, on every attempt and on a resend
typeThe event (member.joined, purchase.completed…) or ping, the test
versionThe version of that event's data format. Today, 1 for all
occurredAtWhen the fact happened in Memberfy (ISO 8601, UTC), not when the delivery went out
communityThe community: id and slug
dataWhat 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:

FamilyEvents
membermember.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
purchasepurchase.completed, purchase.refunded
subscriptionsubscription.created, subscription.renewed, subscription.canceled, subscription.ended, subscription.plan_changed, subscription.past_due, subscription.trial_ending
eventevent.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
coursecourse.enrolled, course.completed, course.published, course.updated, course.left
certificatecertificate.issued, certificate.revoked
formform.submitted, form.submission_reviewed, form.published, form.closed, form.reopened
lessonlesson.completed, lesson.rated, lesson.published
commentcomment.created, comment.deleted
reactionreaction.added, reaction.removed
postpost.created, post.updated, post.moved, post.deleted
checkoutcheckout.started, checkout.abandoned, checkout.recovered
spacespace.first_visit, space.access_granted, space.created, space.updated, space.deleted
quizquiz.passed, quiz.failed
couponcoupon.redeemed
surveysurvey.answered
proposalproposal.submitted, proposal.decided
upvoteupvote.topic_created, upvote.topic_status_changed
downloaddownload.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
classifiedclassified.published, classified.interest_received
parcelparcel.received, parcel.picked_up
visitorvisitor.invited, visitor.arrived
attendanceattendance.checked_in, attendance.checked_out
support_ticketsupport_ticket.created, support_ticket.resolved
bookingbooking.created, booking.canceled, booking.checked_in, booking.no_show
messagemessage.received (without the message text)
badgebadge.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) 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, with the webhook's secret (it starts with whsec_ and is shown only once, on creation or when rotating). Three headers:

HeaderWhat it is
webhook-idThe event id (the same id as the body)
webhook-timestampWhen the attempt went out, in seconds since 1970
webhook-signaturev1, 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 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.
  • 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 answerWhat happens
2xxDelivered
410 GoneThe webhook is disabled right away, with no more attempts, and the owner and admins get an email
429Tries 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 errorFailure; 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.

Limits

LimitValue
Webhooks per community10
Create, send test, turn back on, resend and change the URL10 per minute per community, combined; above that, 429 with Retry-After
Delivery and change history30 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}'
FieldTypeNote
urltext (up to 2048)Same check as on creation (https, port 443, public address); counts toward the limit of 10 per minute per community
descriptiontext (up to 200) or null
eventslist (1 to 50)Types or whole families (member.*)
fixedIpsbooleanTurns 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 (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.