Skip to content
Developers

Community webhooks

Mateflow sends an HTTP POST to your URL when something happens in a community. This page covers what your receiver gets, how to verify it, what happens when it fails, and every field of every event.

Quick start

Each delivery is a POST with a JSON body. If several webhooks subscribe to an event, each one gets its own copy.

  1. In your community admin, open Settings → Integrations → Webhooks and create a webhook: enter the URL, choose the events, generate a secret and turn it on.
  2. Handle each request in this order:
    1. Read the raw request body and verify the signature.
    2. If test is true, return 200 and stop.
    3. De-duplicate on event_id.
    4. Put the work on your own queue and return a 2xx within 10 seconds.
  3. Click Test Webhook to send a test request, then open Delivery Logs to see the request and the response.

Plans and setup

Webhooks are a plan feature, and only community admins can manage them.

PlanWebhooks per community
FreeNot available
StarterNot available
Growth5
Business20
EnterpriseUnlimited
  • After a downgrade to a plan without webhooks, your webhook settings are kept, but nothing is delivered or logged. Upgrading restores delivery.
  • Webhooks are created and managed in the admin console. The Community Open API has no endpoints for them.

Configuration

SettingRequirement
URLThe admin form accepts https:// URLs only, up to 1,024 characters.
Unreachable addressesRequests to addresses that resolve to loopback, private or link-local ranges are blocked at connection time and treated as a network error. For local development, use a public tunnel.
NameUp to 100 characters.
EventsAt least one, from the catalogue below. A webhook saved earlier with an empty event list receives every event.
SecretOptional, up to 128 characters. Once saved it can be replaced but not read back. Without a secret, requests are not signed, so we strongly recommend setting one. The secret itself never appears in a request.

Delivery format

Every delivery is a POST with a 10-second timeout per request.

Headers

HeaderValueNotes
Content-Typeapplication/jsonAlways JSON.
User-AgentMateflow-Webhook/1.0Identifies Mateflow as the sender.
X-Mateflow-TimestampUnix secondsWhen this request was sent. Part of the signature.
X-Mateflow-Signaturesha256= followed by a hex digestOnly present when the webhook has a secret.

Custom webhooks have no X-Mateflow-Delivery-Id header. Read delivery_id from the body.

Body

FieldTypeDescription
eventstringThe event code.
event_idstringThe event's unique identifier. De-duplicate on this. It stays the same across automatic retries and manual resends, and across every webhook subscribed to the event. Treat it as an opaque string.
occurred_atRFC 3339When the event happened, to the second. It stays the same across retries.
delivery_idstring (decimal)Identifies this delivery. It changes on each automatic retry; a manual resend keeps the original value.
timestampRFC 3339When the body was generated. A manual resend keeps the original value, so use the header to judge freshness.
webhook_idhashidThe webhook receiving this delivery.
site_idhashidThe community.
dataobjectThe event's data, described in the event catalogue.

In rare cases, when ID generation fails, event_id and occurred_at can be missing. Accept that, and fall back to event plus the business ID in data to spot duplicates.

JSON
{
  "event": "payment.received",
  "event_id": "86116692152877061",
  "occurred_at": "2026-11-03T00:00:00Z",
  "delivery_id": "86116692152877060",
  "timestamp": "2026-11-03T00:00:01Z",
  "webhook_id": "qP3aXm71Bd",
  "site_id": "Z5Kf08mFv8r",
  "data": {
    "member_id": "b7Lq2WxR9e",
    "username": "sample_member",
    "email": "sample.member@example.com",
    "invoice_id": "in_1Abc",
    "subscription_id": "sub_1Abc",
    "amount_cents": 2900,
    "currency": "usd",
    "period_start": "2026-11-03T00:00:00Z",
    "period_end": "2026-12-03T00:00:00Z"
  }
}

Verify signatures

When a webhook has a secret, every request is signed with HMAC-SHA256 over the timestamp header and the raw body.

Signature
X-Mateflow-Signature = "sha256=" + hex(
  HMAC-SHA256(secret, X-Mateflow-Timestamp + "." + raw_request_body)
)
  • Use the raw bytes. Characters that are special in HTML can be escaped in the body, so parsing the JSON and serializing it again produces different bytes and the signature will not match.
  • Take the timestamp from the header, not the body. A manual resend updates only the header timestamp and the signature.
  • A timestamp check does not interfere with retries. Every automatic retry and manual resend is signed again, so a 5-minute window is enough.
Node.js
const crypto = require('crypto');

// rawBody must be the untouched bytes, e.g. from express.raw({ type: 'application/json' }).
function verifyMateflowSignature(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-mateflow-timestamp'];
  const received = headers['x-mateflow-signature'] || '';
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hashlib
import hmac
import time


def verify_mateflow_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
    timestamp = headers.get("X-Mateflow-Timestamp", "")
    received = headers.get("X-Mateflow-Signature", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received.encode(), expected.encode())

Test vector

Use these values to check your signature calculation. The timestamp has expired, so relax your time window while testing.

Test vector
secret                 my_signing_secret
X-Mateflow-Timestamp   1793664000
raw body (UTF-8)       {"event":"member.joined","data":{"username":"张三"}}
X-Mateflow-Signature   sha256=3f4c83828ce0086150c9bd48c1654899357b7c05265c1d1b6c172225ee04b4b4

Rotating a secret

A new secret takes effect as soon as it is saved, and queued retries are signed with it too. There is no period when Mateflow signs with both, so have your receiver accept both the old and the new secret during the switch.

Responses and retries

How a delivery is judged depends only on your status code. The response body is not parsed.

Your receiverResultRetried
2xxSuccessNo
5xx, 408, 429FailureYes
Timeout, connection failure, blocked addressFailure, logged with status 0Yes
Any other 4xxFailureNo
  • Retry schedule: after a failure, Mateflow waits 1 minute, 10 minutes, 1 hour, then 6 hours. With the first attempt that makes at most 5 attempts over about 7 hours.
  • When delivery gives up: once retries run out, or on a status that is not retried, the community owner and admins get an email, at most one per webhook every 24 hours.
  • Stopped mid-way: if the webhook is deleted or turned off, or the plan no longer includes webhooks, later attempts are neither sent nor logged.
  • Redirects: deliveries follow redirects, but a 301, 302 or 303 turns the POST into a GET with no body. Enter the final URL, and don't rely on an http to https redirect.

Manual resend

Resend in Delivery Logs sends a delivery again to the webhook's current URL.

  • The body is identical, including event_id, delivery_id and timestamp. The timestamp header and the signature are new.
  • It tries once and is not retried on failure. The result appears as a new row in the log.
  • Only custom webhook deliveries can be resent, and the webhook must still exist and be turned on.

Delivery logs

Logs are kept for 30 days. Only the first 4 KB of each response body is saved.

Reliability

What your receiver can and cannot assume.

  • At least once. The same event can arrive more than once. De-duplicate on event_id.
  • Add a second check for anything involving money. Mateflow already de-duplicates the same payment and the same subscription start or end, but if that check is unavailable it sends rather than drops, and the two events then have different event_id values. Check invoice_id, subscription_id or order_id as well.
  • No ordering guarantee. Deliveries run in parallel and are retried, so a member's access_group_removed and access_group_added can arrive reversed. To work out which came first, use occurred_at, which is accurate to the second.
  • Rare loss is possible. Events are queued after the data is committed, so a process that exits between those two steps loses the event.
  • Reconcile if you sync state. Treat an event as a signal that something changed, read the current state from the Open API (for example /member/get with the members.read scope), and run a full comparison periodically. The Open API does not return email addresses, so take those from events.

Event catalogue

You can subscribe to 17 events.

Rules for every field

  • Mateflow IDs are hashid strings, the same values the API uses. A value of 0 is not encoded and appears as the number 0.
  • member_id and user_id refer to the same person. member.joined, post events and comment events use user_id; everything else uses member_id.
  • Stripe IDs are passed through unchanged: invoice_id, subscription_id, charge_id and payment_intent_id.
  • Times are RFC 3339 in UTC.
  • Amounts are integers in the currency's smallest unit (cents for USD), and currency is a lowercase currency code.
  • An optional field that does not apply is left out. It is not null or an empty string.
  • Fields and enum values are only ever added, never changed. Your receiver should tolerate new ones.

All events

EventMeaningUsername and email
member.joinedA new member joinedYes
member.leftA member left the communityYes
member.role_changedA member's role changedNo
member.suspendedA member was suspended or bannedNo
member.reinstatedA suspension or ban was liftedNo
member.access_group_addedA member started holding an access groupYes
member.access_group_removedA member stopped holding an access groupYes
post.createdA post was publishedNo
post.deletedA post was deletedNo
comment.createdA comment was publishedNo
comment.deletedA comment was deletedNo
payment.receivedA payment arrived, for a subscription invoice or a one-time purchaseYes
subscription.renewedA subscription renewed successfullyYes
payment.failedAn invoice payment failedYes
subscription.createdA subscription was created in StripeYes
subscription.canceledA subscription endedYes
refund.issuedA refund was issuedYes

Members

member.joined

A new member joined.

FieldTypeAlways presentDescription
user_idhashidYesMember ID.
usernamestringYesUsername.
emailstringYesEmail address.

Sent when

  • A signup succeeds, including social login, a first SSO login and signing up through an invitation.
  • A paid signup completes.
  • A join request is approved.
  • Someone registers again with the same email and their account is restored. The event is sent again.

Not sent when

  • Members are imported from a CSV file.
  • A member is still waiting for approval in a community that reviews join requests.

member.left

A member left the community.

FieldTypeAlways presentDescription
member_idhashidYesThe same value as the member's user_id in member.joined.
usernamestringYesUsername before leaving, even if leaving erases their personal details.
emailstringYesEmail address before leaving, on the same terms.
sourcestringYesHow they left. See the table below.
removed_byhashidNoThe admin who acted. Only for removed and anonymized.
reasonstringNoOnly for removed, when the admin gave a reason.
sourceMeaningRegistering again with the same email
removedAn admin removed the memberRestores the same ID and sends member.joined again
account_deletedThe member deleted their own account, immediately or when the 30-day cooling-off period endedImmediate deletion: same as above. After cooling-off: a new ID
anonymizedAn admin anonymized the member under GDPRA new ID
inactivityInactive-account cleanup, which runs in nightly batchesA new ID

Not sent when

  • An account is deactivated.
  • A member is inside the cooling-off period after asking to delete their account. It is sent when the period ends, and not at all if they cancel.
  • A member is banned or suspended, which sends member.suspended.
  • Someone never joined, such as a rejected or pending join request.
  • A subscription ends.
  • The community is deleted.
  • A member leaves a single space.
  • Access groups cleared by leaving are not sent one by one as member.access_group_removed. Treat member.left as meaning the member no longer holds any access group.

member.role_changed

A member's role changed.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
previous_rolestringYesmember, moderator or admin, always lowercase.
rolestringYesThe same values.
changed_byhashidYesWho made the change.

Not sent when

  • The role did not actually change.
  • Only Billing Admin or a custom role changed.

member.suspended

A member was suspended or banned. Both use this event, told apart by ban_type.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
suspended_byhashidYesThe admin who acted.
reasonstringYesThe admin's internal note. May be an empty string. Don't show it to the member.
ban_typestringYessuspend or ban.
duration_daysnumberNoOnly for suspend.
suspended_untilRFC 3339NoOnly for suspend.
viastringNomoderation when done while handling a report. Left out when done from member management.

Sent when

  • A member is suspended or banned from member management or while handling a report, one at a time or in bulk.

Not sent when

  • A member is only warned.

member.reinstated

A suspension or ban was lifted.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
reinstated_byhashidNoThe admin who acted. Left out when via is expired.
fromstringYesWhat was lifted: suspend or ban.
viastringNomoderation (lifted in moderation), appeal (appeal approved) or expired (the suspension ran out). Left out when lifted from member management.

Not sent when

  • A suspension ran out but the member has another active ban, so they are still restricted.
  • When a suspension runs out, the event usually goes out within a few minutes, because expiries are checked every 5 minutes. occurred_at is when the expiry was found.

member.access_group_added, member.access_group_removed

A member started or stopped holding an access group. Each event covers one member and one access group, so moving a subscription from Silver to Gold sends one removed and one added.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
usernamestringYesUsername.
emailstringYesEmail address.
access_group_idhashidYesThe same value as access_groups[].id in the Open API.
access_group_namestringYesThe group's name at the time. For a deleted group, the name before deletion.
sourcestringYesSee the table below.
changed_byhashidNoOnly for manual and invitation.
sourceEventsMeaningchanged_by
manualadded, removedAn admin granted or removed it in the consoleThe admin
subscriptionadded, removedA subscription started, ended or changed tierNone
invitationaddedThe member accepted an invitationThe inviter
defaultaddedThe member entered the default groupNone
group_deletedremovedThe access group was deletedNone

Not sent when

  • The member's set of groups did not actually change.
  • A member enters the default group automatically at signup.
  • The change affects a member still waiting for approval.
  • Groups are cleared because the account was deleted.
  • Deleting an access group or setting a default group is a bulk change. Events are sent member by member and arrive seconds to minutes after the action, while occurred_at stays the time of the action. Their event_id has a different format, which is why it must be treated as opaque.

Content

In all four events, post_id is the ID of the content itself and user_id is the author, not whoever acted. space_id is left out when the content is not in a space. Content in private spaces is sent too, so filter on space_id if your receiver needs to.

post.created

A post or blog post was published.

FieldTypeAlways presentDescription
post_idhashidYesPost ID.
user_idhashidYesThe author.
space_idhashidNoThe space containing it.
titlestringYesTitle. May be an empty string.
bodystringYesMarkdown source, up to 500 characters. It may be cut early at a word or punctuation mark, with no ellipsis. For a blog post this is the excerpt.

Sent when

  • A post or blog post becomes public: it is published directly, approved from the review queue, or reaches its scheduled time.

Not sent when

  • A comment is published, which sends comment.created.
  • A blog post sends this on first publication only, but one that was unpublished and then scheduled again may send it again. Make your handling idempotent on post_id.

post.deleted

A post or blog post was deleted.

FieldTypeAlways presentDescription
post_idhashidYesPost ID.
user_idhashidYesThe author.
space_idhashidNoThe space containing it.

Sent when

  • The author deletes it, an admin deletes it (singly or in bulk), it is removed while handling a report, or a ban clears the member's content. Clearing a member's content can produce a batch of events.

Not sent when

  • A blog post is unpublished back to a draft, which is not a deletion.
  • Deleting a scheduled post that was never published also sends this, so receiving it does not mean you received post.created earlier.
  • There is no separate event for restoring content.

comment.created

A comment was published.

FieldTypeAlways presentDescription
post_idhashidYesThe comment's own ID.
parent_idhashidYesThe post or comment being replied to.
user_idhashidYesThe author.
space_idhashidNoThe space containing it.
bodystringYesThe same rules as body in post.created.

comment.deleted

A comment was deleted. It is sent under the same conditions as post.deleted.

FieldTypeAlways presentDescription
post_idhashidYesThe comment's own ID.
parent_idhashidYesThe post or comment being replied to.
user_idhashidYesThe author.
space_idhashidNoThe space containing it.

Payments and subscriptions

These events cover Stripe subscriptions, one-time space purchases and refunds. They carry username and email, unless the member no longer exists.

In-app purchases and subscriptions granted as a gift or through a redemption code do not send payment.* or subscription.*. If the plan is tied to an access group, they send member.access_group_added with source set to subscription, and member.access_group_removed when access ends, which you can use to track entitlement changes.

In a paid signup the payment comes before the account, so the first payment's events are sent once the account exists: member.joined, subscription.created, and payment.received if the first invoice is paid. They can arrive in any order.

payment.received

A payment arrived. There are two payload shapes, and you can tell them apart by whether invoice_id is present.

FieldTypeSubscription invoiceOne-time purchaseDescription
member_idhashidYesYesMember ID.
usernamestringUsuallyUsuallyLeft out if the member no longer exists.
emailstringUsuallyUsuallyLeft out if the member no longer exists.
amount_centsnumberYesYesThe amount paid. 0 for the zero-amount invoice when a trial starts.
currencystringYesYesCurrency.
invoice_idstringYesNoStripe invoice ID.
subscription_idstringYesNoStripe subscription ID.
period_startRFC 3339YesNoStart of the period this payment bought.
period_endRFC 3339YesNoEnd of the period this payment bought. You can use it as the access expiry.
order_idhashidNoYesOrder ID.
space_idhashidNoYesThe space purchased.
payment_intent_idstringNoYesStripe PaymentIntent ID.
  • The event has no plan_id.
  • A one-time purchase sends this after access is granted, once per order.

subscription.renewed

A subscription renewed successfully. The fields are the same as the subscription invoice form of payment.received.

  • A renewal sends both events, with different event_id values. If you subscribe to both, don't record the revenue twice.

payment.failed

An invoice payment failed. It is sent on every failure, including each time Stripe retries the charge.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
usernamestringNoLeft out if the member no longer exists.
emailstringNoLeft out if the member no longer exists.
invoice_idstringYesStripe invoice ID.
subscription_idstringYesStripe subscription ID.
amount_due_centsnumberYesThe amount due.
currencystringYesCurrency.
attempt_countnumberYesWhich attempt this is for the invoice.

subscription.created

A subscription was created in Stripe. This does not mean it has been paid; use payment.received for that.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
usernamestringNoLeft out if the member no longer exists.
emailstringNoLeft out if the member no longer exists.
subscription_idstringYesStripe subscription ID.
plan_idhashidYesThe member subscription plan ID.
statusstringYesCommon values: active, trialing and incomplete (first invoice not settled).
price_centsnumberYesThe plan's unit price, before discounts.
currencystringYesCurrency.
billing_periodstringYesmonthly or annual.
period_startRFC 3339YesStart of the current period.
period_endRFC 3339YesEnd of the current period.

subscription.canceled

A subscription actually ended. A member scheduling cancellation at period end does not send it; the end of the period does.

FieldTypeAlways presentDescription
member_idhashidYesMember ID.
usernamestringNoLeft out if the member no longer exists.
emailstringNoLeft out if the member no longer exists.
subscription_idstringYesStripe subscription ID.
plan_idhashidYesThe member subscription plan ID.
previous_statusstringYesThe status before it ended, such as active, trialing or past_due.
cancelled_atRFC 3339YesWhen it ended.
  • A subscription that expires without ever being paid also sends this, with previous_status set to incomplete. That member never had access, so don't count it as churn.
  • The spelling is inconsistent: the event is canceled, the field is cancelled_at, and the subscription status value is cancelled.

refund.issued

A refund was issued. Refunds of subscription invoices and one-time purchases share this event with different fields. You can tell them apart by whether invoice_id is present.

FieldTypeSubscription invoiceOne-time purchaseDescription
member_idhashidYesYesMember ID.
usernamestringUsuallyUsuallyLeft out if the member no longer exists.
emailstringUsuallyUsuallyLeft out if the member no longer exists.
amount_refunded_centsnumberYesYesThe total refunded so far, not the amount of this refund.
currencystringYesYesCurrency.
refund_statusstringrefunded or partially_refundedrefundedThe status after the refund.
is_partialbooleanYesAlways falseWhether this is a partial refund.
previous_statusstringFor example paid or partially_refundedcompletedThe status before the refund.
invoice_idstringYesNoStripe invoice ID.
charge_idstringYes, may be an empty stringOnly for refunds started in StripeStripe charge ID.
order_idhashidNoYesOrder ID.
space_idhashidNoYesThe space purchased.
payment_intent_idstringNoYesStripe PaymentIntent ID.
  • A partial refund of a one-time purchase made in Stripe sends no event, and the order and access stay as they were.

Test deliveries

Test Webhook in the admin console sends one request immediately and writes the result to the delivery log. A webhook that is turned off can be tested too, and the test is not retried. The format is the same as a real delivery, including the signature, except for the following.

ItemTest deliveryReal delivery
testtrueNot present
eventAlways member.joined, whichever events the webhook subscribes toThe subscribed event
dataSample dataReal data
event_id, occurred_atNot presentPresent

Check test first and return 200 when it is true. Otherwise a test of member.joined could really create a member in your system.

Other channels

Delivery Logs also shows these sources. They use the same events as custom webhooks, in different formats.

ChannelBodySignatureOther differences
ZapierThe same data. The envelope adds a boolean test and has no webhook_id.Only if a signing secret was set when connecting. Same algorithm.Adds an X-Mateflow-Delivery-Id header.
Slack, DiscordReadable text only, as text for Slack and content for Discord.Same as Zapier.Adds an X-Mateflow-Delivery-Id header. No structured fields.
Automation rule: Trigger webhookThe template written in the rule.Only if the rule has a secret. Same algorithm.Does not use this envelope. The rule decides when it sends.
Call back into your community with the Open API

Start building

Create a webhook in your community admin console, send a test delivery, and check it in the logs.

14-day free trial · Then Free for up to 100 members · No credit card