member.joined
有新成员加入。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| user_id | hashid | 是 | 成员 ID。 |
| username | string | 是 | 用户名。 |
| string | 是 | 邮箱地址。 |
发送时机
- 注册成功,包括社交登录、首次 SSO 登录以及通过邀请注册。
- 付费注册完成。
- 加入申请被批准。
- 有人用相同邮箱重新注册并恢复了账号。事件会再次发送。
不发送的情形
- 从 CSV 文件导入成员。
- 在需要审核加入申请的社区中,成员仍在等待批准。
每次投递都是一个带 JSON 请求体的 POST 请求。如果多个 Webhook 订阅了同一个事件,每个 Webhook 都会收到各自的一份。
test 为 true,返回 200 并停止处理。event_id 去重。Webhook 是套餐功能,并且只有社区管理员可以管理。
| 套餐 | 每个社区的 Webhook 数量 |
|---|---|
| Free | 不可用 |
| Starter | 不可用 |
| Growth | 5 |
| Business | 20 |
| Enterprise | 不限 |
配置
| 设置项 | 要求 |
|---|---|
| URL | 管理后台的表单只接受 https:// URL,最长 1,024 个字符。 |
| 无法访问的地址 | 解析到回环、私有或链路本地地址段的请求会在建立连接时被拦截,并按网络错误处理。本地开发请使用公网隧道。 |
| 名称 | 最长 100 个字符。 |
| 事件 | 至少选一个,从下方的事件目录中选择。此前保存时事件列表为空的 Webhook 会接收所有事件。 |
| 密钥 | 可选,最长 128 个字符。保存后可以替换,但无法再读回。没有密钥时请求不会被签名,因此强烈建议设置密钥。密钥本身绝不会出现在请求中。 |
每次投递都是一个 POST 请求,每个请求的超时时间为 10 秒。
请求头
| 请求头 | 值 | 说明 |
|---|---|---|
Content-Type | application/json | 始终为 JSON。 |
User-Agent | Mateflow-Webhook/1.0 | 表明发送方是 Mateflow。 |
X-Mateflow-Timestamp | Unix 秒 | 本次请求的发送时间。属于签名的一部分。 |
X-Mateflow-Signature | sha256= 后接十六进制摘要 | 仅当该 Webhook 设置了密钥时才有。 |
自定义 Webhook 没有 X-Mateflow-Delivery-Id 请求头。请从请求体中读取 delivery_id。
请求体
| 字段 | 类型 | 说明 |
|---|---|---|
event | string | 事件代码。 |
event_id | string | 事件的唯一标识。请按它去重。它在自动重试和手动重新发送之间保持不变,并且订阅了该事件的所有 Webhook 看到的值都相同。请把它当作不透明字符串。 |
occurred_at | RFC 3339 | 事件发生的时间,精确到秒。重试时保持不变。 |
delivery_id | string (decimal) | 标识本次投递。每次自动重试都会变化;手动重新发送则保留原值。 |
timestamp | RFC 3339 | 请求体的生成时间。手动重新发送会保留原值,因此请用请求头来判断时效。 |
webhook_id | hashid | 接收本次投递的 Webhook。 |
site_id | hashid | 所属社区。 |
data | object | 事件的数据,详见事件目录。 |
在少数情况下,ID 生成失败时,event_id 和 occurred_at 可能缺失。请接受这种情况,并改用 event 加上 data 中的业务 ID 来识别重复。
{
"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"
}
}Webhook 设置了密钥时,每个请求都会用 HMAC-SHA256 对时间戳请求头和原始请求体进行签名。
X-Mateflow-Signature = "sha256=" + hex( HMAC-SHA256(secret, X-Mateflow-Timestamp + "." + raw_request_body) )
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);
}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())测试向量
用这些值来检查你的签名计算。其中的时间戳已经过期,所以测试时请放宽你的时间窗口。
secret my_signing_secret
X-Mateflow-Timestamp 1793664000
raw body (UTF-8) {"event":"member.joined","data":{"username":"张三"}}
X-Mateflow-Signature sha256=3f4c83828ce0086150c9bd48c1654899357b7c05265c1d1b6c172225ee04b4b4轮换密钥
新密钥保存后立即生效,排队中的重试也会用它签名。Mateflow 不存在同时用新旧两个密钥签名的时段,所以切换期间请让你的接收方同时接受旧密钥和新密钥。
一次投递如何判定,只取决于你返回的状态码。响应体不会被解析。
| 你的接收方 | 结果 | 是否重试 |
|---|---|---|
| 2xx | 成功 | 否 |
| 5xx、408、429 | 失败 | 是 |
| 超时、连接失败、地址被拦截 | 失败,日志中记录为状态 0 | 是 |
| 其他任何 4xx | 失败 | 否 |
http 到 https 的重定向。手动重新发送
投递日志中的重新发送会把一次投递再次发送到该 Webhook 当前的 URL。
event_id、delivery_id 和 timestamp。时间戳请求头和签名是新的。投递日志
日志保留 30 天。每个响应体只保存前 4 KB。
接收方可以假定什么,不可以假定什么。
event_id 去重。event_id 值不同。请同时检查 invoice_id、subscription_id 或 order_id。access_group_removed 和 access_group_added 可能以相反的顺序到达。要判断哪个在先,请使用 occurred_at,它精确到秒。members.read scope 调用 /member/get),并定期做一次完整比对。Open API 不返回邮箱地址,所以邮箱请从事件中获取。你可以订阅 17 个事件。
适用于所有字段的规则
0 出现。member_id 和 user_id 指的是同一个人。member.joined、帖子事件和评论事件使用 user_id;其余都使用 member_id。invoice_id、subscription_id、charge_id 和 payment_intent_id。currency 是小写的币种代码。null 或空字符串。全部事件
| 事件 | 含义 | 用户名和邮箱 |
|---|---|---|
| member.joined | 有新成员加入 | 是 |
| member.left | 有成员离开社区 | 是 |
| member.role_changed | 成员的角色发生变化 | 否 |
| member.suspended | 成员被暂停或封禁 | 否 |
| member.reinstated | 暂停或封禁被解除 | 否 |
| member.access_group_added | 成员开始持有某个访问组 | 是 |
| member.access_group_removed | 成员不再持有某个访问组 | 是 |
| post.created | 发布了一篇帖子 | 否 |
| post.deleted | 删除了一篇帖子 | 否 |
| comment.created | 发布了一条评论 | 否 |
| comment.deleted | 删除了一条评论 | 否 |
| payment.received | 收到一笔付款,来自订阅账单或一次性购买 | 是 |
| subscription.renewed | 订阅成功续订 | 是 |
| payment.failed | 账单付款失败 | 是 |
| subscription.created | 在 Stripe 中创建了订阅 | 是 |
| subscription.canceled | 订阅已结束 | 是 |
| refund.issued | 已发起退款 | 是 |
有新成员加入。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| user_id | hashid | 是 | 成员 ID。 |
| username | string | 是 | 用户名。 |
| string | 是 | 邮箱地址。 |
发送时机
不发送的情形
有成员离开了社区。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 与 member.joined 中该成员的 user_id 值相同。 |
| username | string | 是 | 离开前的用户名,即使离开会抹除其个人信息也是如此。 |
| string | 是 | 离开前的邮箱地址,条件同上。 | |
| source | string | 是 | 离开的方式。见下表。 |
| removed_by | hashid | 否 | 执行操作的管理员。仅适用于 removed 和 anonymized。 |
| reason | string | 否 | 仅适用于 removed,且管理员填写了原因时。 |
| source | 含义 | 用相同邮箱重新注册 |
|---|---|---|
removed | 管理员移除了该成员 | 恢复原来的 ID,并再次发送 member.joined |
account_deleted | 该成员自行删除了账号,立即删除或在 30 天冷静期结束时删除 | 立即删除:同上。冷静期结束后删除:新的 ID |
anonymized | 管理员依据 GDPR 对该成员做了匿名化 | 新的 ID |
inactivity | 不活跃账号清理,按夜间批次运行 | 新的 ID |
不发送的情形
member.suspended。member.access_group_removed 发送。请把 member.left 理解为该成员不再持有任何访问组。成员的角色发生变化。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| previous_role | string | 是 | member、moderator 或 admin,始终为小写。 |
| role | string | 是 | 取值相同。 |
| changed_by | hashid | 是 | 做出变更的人。 |
不发送的情形
成员被暂停或封禁。两者使用同一个事件,通过 ban_type 区分。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| suspended_by | hashid | 是 | 执行操作的管理员。 |
| reason | string | 是 | 管理员的内部备注。可能是空字符串。不要展示给该成员。 |
| ban_type | string | 是 | suspend 或 ban。 |
| duration_days | number | 否 | 仅适用于 suspend。 |
| suspended_until | RFC 3339 | 否 | 仅适用于 suspend。 |
| via | string | 否 | 在处理举报时执行则为 moderation。在成员管理中执行则省略。 |
发送时机
不发送的情形
暂停或封禁被解除。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| reinstated_by | hashid | 否 | 执行操作的管理员。via 为 expired 时省略。 |
| from | string | 是 | 被解除的是什么:suspend 或 ban。 |
| via | string | 否 | moderation(在审核中解除)、appeal(申诉被批准)或 expired(暂停期满)。在成员管理中解除则省略。 |
不发送的情形
occurred_at 是发现到期的时间。成员开始或不再持有某个访问组。每个事件只涉及一位成员和一个访问组,所以把订阅从 Silver 换到 Gold 会发送一个 removed 和一个 added。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| username | string | 是 | 用户名。 |
| string | 是 | 邮箱地址。 | |
| access_group_id | hashid | 是 | 与 Open API 中的 access_groups[].id 值相同。 |
| access_group_name | string | 是 | 该访问组当时的名称。对于已删除的访问组,则是删除前的名称。 |
| source | string | 是 | 见下表。 |
| changed_by | hashid | 否 | 仅适用于 manual 和 invitation。 |
| source | 事件 | 含义 | changed_by |
|---|---|---|---|
manual | added, removed | 管理员在后台授予或移除 | 该管理员 |
subscription | added, removed | 订阅开始、结束或更换了档位 | 无 |
invitation | added | 该成员接受了邀请 | 邀请人 |
default | added | 该成员进入了默认访问组 | 无 |
group_deleted | removed | 该访问组被删除 | 无 |
不发送的情形
occurred_at 仍是操作发生的时间。它们的 event_id 格式不同,这正是必须把它当作不透明字符串的原因。这四个事件中,post_id 都是内容本身的 ID,user_id 是作者,而不是执行操作的人。内容不在任何空间中时,会省略 space_id。私密空间中的内容同样会发送,所以如果你的接收方需要过滤,请按 space_id 过滤。
发布了一篇帖子或博客文章。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| post_id | hashid | 是 | 帖子 ID。 |
| user_id | hashid | 是 | 作者。 |
| space_id | hashid | 否 | 内容所在的空间。 |
| title | string | 是 | 标题。可能是空字符串。 |
| body | string | 是 | Markdown 源文本,最长 500 个字符。可能在某个词或标点处提前截断,且不带省略号。对于博客文章,这是摘要。 |
发送时机
不发送的情形
comment.created。post_id 保持幂等。删除了一篇帖子或博客文章。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| post_id | hashid | 是 | 帖子 ID。 |
| user_id | hashid | 是 | 作者。 |
| space_id | hashid | 否 | 内容所在的空间。 |
发送时机
不发送的情形
post.created。发布了一条评论。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| post_id | hashid | 是 | 评论自己的 ID。 |
| parent_id | hashid | 是 | 被回复的帖子或评论。 |
| user_id | hashid | 是 | 作者。 |
| space_id | hashid | 否 | 内容所在的空间。 |
| body | string | 是 | 规则与 post.created 中的 body 相同。 |
删除了一条评论。发送条件与 post.deleted 相同。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| post_id | hashid | 是 | 评论自己的 ID。 |
| parent_id | hashid | 是 | 被回复的帖子或评论。 |
| user_id | hashid | 是 | 作者。 |
| space_id | hashid | 否 | 内容所在的空间。 |
这些事件涵盖 Stripe 订阅、一次性空间购买和退款。除非该成员已不存在,否则它们会带有 username 和 email。
应用内购买以及以赠送或兑换码方式获得的订阅不会发送 payment.* 或 subscription.*。如果该套餐关联了访问组,它们会发送 member.access_group_added(source 为 subscription),访问权限结束时发送 member.access_group_removed,你可以用它们来追踪权益的变化。
付费注册时,付款发生在账号创建之前,所以首次付款对应的事件会在账号创建后发送:member.joined、subscription.created,以及在首张账单已支付时的 payment.received。它们可能以任意顺序到达。
收到一笔付款。有两种负载形态,可以通过是否存在 invoice_id 来区分。
| 字段 | 类型 | 订阅账单 | 一次性购买 | 说明 |
|---|---|---|---|---|
| member_id | hashid | 是 | 是 | 成员 ID。 |
| username | string | 通常如此 | 通常如此 | 该成员已不存在时省略。 |
| string | 通常如此 | 通常如此 | 该成员已不存在时省略。 | |
| amount_cents | number | 是 | 是 | 已支付的金额。试用开始时的零金额账单为 0。 |
| currency | string | 是 | 是 | 币种。 |
| invoice_id | string | 是 | 否 | Stripe 账单 ID。 |
| subscription_id | string | 是 | 否 | Stripe 订阅 ID。 |
| period_start | RFC 3339 | 是 | 否 | 这笔付款所购买的周期的开始时间。 |
| period_end | RFC 3339 | 是 | 否 | 这笔付款所购买的周期的结束时间。你可以把它用作访问权限的到期时间。 |
| order_id | hashid | 否 | 是 | 订单 ID。 |
| space_id | hashid | 否 | 是 | 所购买的空间。 |
| payment_intent_id | string | 否 | 是 | Stripe PaymentIntent ID。 |
plan_id。订阅成功续订。字段与 payment.received 的订阅账单形态相同。
event_id 值不同。如果你同时订阅了两者,请不要把收入记两次。账单付款失败。每次失败都会发送,包括 Stripe 每次重试扣款时。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| username | string | 否 | 该成员已不存在时省略。 |
| string | 否 | 该成员已不存在时省略。 | |
| invoice_id | string | 是 | Stripe 账单 ID。 |
| subscription_id | string | 是 | Stripe 订阅 ID。 |
| amount_due_cents | number | 是 | 应付金额。 |
| currency | string | 是 | 币种。 |
| attempt_count | number | 是 | 这是该账单的第几次尝试。 |
在 Stripe 中创建了订阅。这并不表示已经付款;付款请看 payment.received。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| username | string | 否 | 该成员已不存在时省略。 |
| string | 否 | 该成员已不存在时省略。 | |
| subscription_id | string | 是 | Stripe 订阅 ID。 |
| plan_id | hashid | 是 | 成员订阅套餐的 ID。 |
| status | string | 是 | 常见取值:active、trialing 和 incomplete(首张账单尚未结清)。 |
| price_cents | number | 是 | 该套餐的单价,折扣之前。 |
| currency | string | 是 | 币种。 |
| billing_period | string | 是 | monthly 或 annual。 |
| period_start | RFC 3339 | 是 | 当前周期的开始时间。 |
| period_end | RFC 3339 | 是 | 当前周期的结束时间。 |
订阅实际结束。成员预约在周期结束时取消并不会发送它;周期结束时才会发送。
| 字段 | 类型 | 是否始终存在 | 说明 |
|---|---|---|---|
| member_id | hashid | 是 | 成员 ID。 |
| username | string | 否 | 该成员已不存在时省略。 |
| string | 否 | 该成员已不存在时省略。 | |
| subscription_id | string | 是 | Stripe 订阅 ID。 |
| plan_id | hashid | 是 | 成员订阅套餐的 ID。 |
| previous_status | string | 是 | 结束之前的状态,例如 active、trialing 或 past_due。 |
| cancelled_at | RFC 3339 | 是 | 结束的时间。 |
previous_status 为 incomplete。该成员从未获得过访问权限,所以不要把它算作流失。canceled,字段是 cancelled_at,订阅状态值是 cancelled。已发起退款。订阅账单的退款和一次性购买的退款共用此事件,但字段不同。可以通过是否存在 invoice_id 来区分。
| 字段 | 类型 | 订阅账单 | 一次性购买 | 说明 |
|---|---|---|---|---|
| member_id | hashid | 是 | 是 | 成员 ID。 |
| username | string | 通常如此 | 通常如此 | 该成员已不存在时省略。 |
| string | 通常如此 | 通常如此 | 该成员已不存在时省略。 | |
| amount_refunded_cents | number | 是 | 是 | 截至目前已退款的总额,而不是本次退款的金额。 |
| currency | string | 是 | 是 | 币种。 |
| refund_status | string | refunded 或 partially_refunded | refunded | 退款之后的状态。 |
| is_partial | boolean | 是 | 始终为 false | 是否为部分退款。 |
| previous_status | string | 例如 paid 或 partially_refunded | completed | 退款之前的状态。 |
| invoice_id | string | 是 | 否 | Stripe 账单 ID。 |
| charge_id | string | 是,可能是空字符串 | 仅限在 Stripe 中发起的退款 | Stripe 扣款(charge)ID。 |
| order_id | hashid | 否 | 是 | 订单 ID。 |
| space_id | hashid | 否 | 是 | 所购买的空间。 |
| payment_intent_id | string | 否 | 是 | Stripe PaymentIntent ID。 |
管理后台中的测试 Webhook 会立即发送一个请求,并把结果写入投递日志。已停用的 Webhook 也能测试,并且不会重试。其格式与真实投递相同,包括签名,但以下几点除外。
| 项目 | 测试投递 | 真实投递 |
|---|---|---|
test | true | 不存在 |
event | 固定为 member.joined,与 Webhook 订阅的事件无关 | 所订阅的事件 |
data | 示例数据 | 真实数据 |
event_id、occurred_at | 不存在 | 存在 |
请先检查 test,为 true 时返回 200。否则对 member.joined 的一次测试可能会在你的系统中真的创建出一个成员。
投递日志中还会显示以下来源。它们使用与自定义 Webhook 相同的事件,但格式不同。
| 渠道 | 请求体 | 签名 | 其他差异 |
|---|---|---|---|
| Zapier | data 相同。信封中多了一个布尔值 test,并且没有 webhook_id。 | 仅当连接时设置了签名密钥才有。算法相同。 | 多一个 X-Mateflow-Delivery-Id 请求头。 |
| Slack、Discord | 只有可读文本,Slack 为 text,Discord 为 content。 | 与 Zapier 相同。 | 多一个 X-Mateflow-Delivery-Id 请求头。没有结构化字段。 |
| 自动化规则:触发 Webhook | 规则中写的模板。 | 仅当规则设置了密钥才有。算法相同。 | 不使用这种信封。由规则决定何时发送。 |