跳到主要内容
开发者

社区 Webhook

社区里有事情发生时,Mateflow 会向你的 URL 发送一个 HTTP POST 请求。本页说明接收方会收到什么、如何校验、失败后会怎样,以及每个事件的每个字段。

快速开始

每次投递都是一个带 JSON 请求体的 POST 请求。如果多个 Webhook 订阅了同一个事件,每个 Webhook 都会收到各自的一份。

  1. 在社区后台打开设置 → 集成 → Webhook,创建一个 Webhook:填写 URL、选择事件、生成密钥并启用。
  2. 请按以下顺序处理每个请求:
    1. 读取原始请求体并校验签名。
    2. 如果 test 为 true,返回 200 并停止处理。
    3. 按 event_id 去重。
    4. 把任务放进你自己的队列,并在 10 秒内返回 2xx。
  3. 点击测试 Webhook 发送一个测试请求,然后打开投递日志查看请求和响应。

套餐与设置

Webhook 是套餐功能,并且只有社区管理员可以管理。

套餐每个社区的 Webhook 数量
Free不可用
Starter不可用
Growth5
Business20
Enterprise不限
  • 降级到不含 Webhook 的套餐后,你的 Webhook 设置会保留,但不会投递,也不会记录日志。升级后恢复投递。
  • Webhook 在管理后台创建和管理。社区 Open API 没有对应的端点。

配置

设置项要求
URL管理后台的表单只接受 https:// URL,最长 1,024 个字符。
无法访问的地址解析到回环、私有或链路本地地址段的请求会在建立连接时被拦截,并按网络错误处理。本地开发请使用公网隧道。
名称最长 100 个字符。
事件至少选一个,从下方的事件目录中选择。此前保存时事件列表为空的 Webhook 会接收所有事件。
密钥可选,最长 128 个字符。保存后可以替换,但无法再读回。没有密钥时请求不会被签名,因此强烈建议设置密钥。密钥本身绝不会出现在请求中。

投递格式

每次投递都是一个 POST 请求,每个请求的超时时间为 10 秒。

请求头

请求头值说明
Content-Typeapplication/json始终为 JSON。
User-AgentMateflow-Webhook/1.0表明发送方是 Mateflow。
X-Mateflow-TimestampUnix 秒本次请求的发送时间。属于签名的一部分。
X-Mateflow-Signaturesha256= 后接十六进制摘要仅当该 Webhook 设置了密钥时才有。

自定义 Webhook 没有 X-Mateflow-Delivery-Id 请求头。请从请求体中读取 delivery_id。

请求体

字段类型说明
eventstring事件代码。
event_idstring事件的唯一标识。请按它去重。它在自动重试和手动重新发送之间保持不变,并且订阅了该事件的所有 Webhook 看到的值都相同。请把它当作不透明字符串。
occurred_atRFC 3339事件发生的时间,精确到秒。重试时保持不变。
delivery_idstring (decimal)标识本次投递。每次自动重试都会变化;手动重新发送则保留原值。
timestampRFC 3339请求体的生成时间。手动重新发送会保留原值,因此请用请求头来判断时效。
webhook_idhashid接收本次投递的 Webhook。
site_idhashid所属社区。
dataobject事件的数据,详见事件目录。

在少数情况下,ID 生成失败时,event_id 和 occurred_at 可能缺失。请接受这种情况,并改用 event 加上 data 中的业务 ID 来识别重复。

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

校验签名

Webhook 设置了密钥时,每个请求都会用 HMAC-SHA256 对时间戳请求头和原始请求体进行签名。

签名
X-Mateflow-Signature = "sha256=" + hex(
  HMAC-SHA256(secret, X-Mateflow-Timestamp + "." + raw_request_body)
)
  • 请使用原始字节。请求体中的 HTML 特殊字符可能被转义,所以先解析 JSON 再重新序列化会得到不同的字节,签名将无法匹配。
  • 时间戳请从请求头取,不要从请求体取。手动重新发送只会更新请求头中的时间戳和签名。
  • 检查时间戳不会影响重试。每次自动重试和手动重新发送都会重新签名,所以 5 分钟的时间窗口就足够了。
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())

测试向量

用这些值来检查你的签名计算。其中的时间戳已经过期,所以测试时请放宽你的时间窗口。

测试向量
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失败否
  • 重试计划:失败后,Mateflow 依次等待 1 分钟、10 分钟、1 小时,再等待 6 小时。加上首次尝试,最多共 5 次尝试,历时约 7 小时。
  • 放弃投递时:重试用尽,或遇到不会重试的状态码时,社区所有者和管理员会收到一封邮件,每个 Webhook 每 24 小时最多一封。
  • 中途停止:如果 Webhook 被删除或停用,或者套餐不再包含 Webhook,之后的尝试既不会发送,也不会记录日志。
  • 重定向:投递会跟随重定向,但 301、302 或 303 会把 POST 变成不带请求体的 GET。请填写最终 URL,不要依赖从 http 到 https 的重定向。

手动重新发送

投递日志中的重新发送会把一次投递再次发送到该 Webhook 当前的 URL。

  • 请求体完全相同,包括 event_id、delivery_id 和 timestamp。时间戳请求头和签名是新的。
  • 只尝试一次,失败后不会重试。结果会作为新的一行出现在日志中。
  • 只有自定义 Webhook 的投递可以重新发送,并且该 Webhook 必须仍然存在且处于启用状态。

投递日志

日志保留 30 天。每个响应体只保存前 4 KB。

可靠性

接收方可以假定什么,不可以假定什么。

  • 至少一次。同一个事件可能多次到达。请按 event_id 去重。
  • 凡涉及金额的,请再加一道检查。Mateflow 已经对同一笔付款、同一次订阅开始或结束做了去重,但如果该检查不可用,它会选择发送而不是丢弃,此时这两个事件的 event_id 值不同。请同时检查 invoice_id、subscription_id 或 order_id。
  • 不保证顺序。投递是并行的并且会重试,所以同一成员的 access_group_removed 和 access_group_added 可能以相反的顺序到达。要判断哪个在先,请使用 occurred_at,它精确到秒。
  • 极少数情况下可能丢失。事件是在数据提交之后才入队的,所以进程恰好在这两步之间退出就会丢失该事件。
  • 如果你要同步状态,请做对账。把事件当作某处发生了变化的信号,从 Open API 读取当前状态(例如使用 members.read scope 调用 /member/get),并定期做一次完整比对。Open API 不返回邮箱地址,所以邮箱请从事件中获取。

事件目录

你可以订阅 17 个事件。

适用于所有字段的规则

  • Mateflow 的 ID 都是 hashid 字符串,与 API 使用的值相同。值为 0 时不会被编码,而是以数字 0 出现。
  • member_id 和 user_id 指的是同一个人。member.joined、帖子事件和评论事件使用 user_id;其余都使用 member_id。
  • Stripe 的 ID 原样透传:invoice_id、subscription_id、charge_id 和 payment_intent_id。
  • 时间为 UTC 的 RFC 3339 格式。
  • 金额是以币种最小单位表示的整数(美元为美分),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已发起退款是

成员

member.joined

有新成员加入。

字段类型是否始终存在说明
user_idhashid是成员 ID。
usernamestring是用户名。
emailstring是邮箱地址。

发送时机

  • 注册成功,包括社交登录、首次 SSO 登录以及通过邀请注册。
  • 付费注册完成。
  • 加入申请被批准。
  • 有人用相同邮箱重新注册并恢复了账号。事件会再次发送。

不发送的情形

  • 从 CSV 文件导入成员。
  • 在需要审核加入申请的社区中,成员仍在等待批准。

member.left

有成员离开了社区。

字段类型是否始终存在说明
member_idhashid是与 member.joined 中该成员的 user_id 值相同。
usernamestring是离开前的用户名,即使离开会抹除其个人信息也是如此。
emailstring是离开前的邮箱地址,条件同上。
sourcestring是离开的方式。见下表。
removed_byhashid否执行操作的管理员。仅适用于 removed 和 anonymized。
reasonstring否仅适用于 removed,且管理员填写了原因时。
source含义用相同邮箱重新注册
removed管理员移除了该成员恢复原来的 ID,并再次发送 member.joined
account_deleted该成员自行删除了账号,立即删除或在 30 天冷静期结束时删除立即删除:同上。冷静期结束后删除:新的 ID
anonymized管理员依据 GDPR 对该成员做了匿名化新的 ID
inactivity不活跃账号清理,按夜间批次运行新的 ID

不发送的情形

  • 账号被停用。
  • 成员申请删除账号后处于冷静期内。冷静期结束时才会发送;如果成员取消,则完全不会发送。
  • 成员被封禁或暂停,此时发送的是 member.suspended。
  • 对方从未加入,例如被拒绝或仍待处理的加入申请。
  • 订阅结束。
  • 社区被删除。
  • 成员只是离开某一个空间。
  • 因离开而被清除的访问组,不会逐个以 member.access_group_removed 发送。请把 member.left 理解为该成员不再持有任何访问组。

member.role_changed

成员的角色发生变化。

字段类型是否始终存在说明
member_idhashid是成员 ID。
previous_rolestring是member、moderator 或 admin,始终为小写。
rolestring是取值相同。
changed_byhashid是做出变更的人。

不发送的情形

  • 角色实际上没有变化。
  • 只有 Billing Admin 或自定义角色发生了变化。

member.suspended

成员被暂停或封禁。两者使用同一个事件,通过 ban_type 区分。

字段类型是否始终存在说明
member_idhashid是成员 ID。
suspended_byhashid是执行操作的管理员。
reasonstring是管理员的内部备注。可能是空字符串。不要展示给该成员。
ban_typestring是suspend 或 ban。
duration_daysnumber否仅适用于 suspend。
suspended_untilRFC 3339否仅适用于 suspend。
viastring否在处理举报时执行则为 moderation。在成员管理中执行则省略。

发送时机

  • 在成员管理中或处理举报时暂停或封禁成员,无论是逐个还是批量操作。

不发送的情形

  • 成员只是被警告。

member.reinstated

暂停或封禁被解除。

字段类型是否始终存在说明
member_idhashid是成员 ID。
reinstated_byhashid否执行操作的管理员。via 为 expired 时省略。
fromstring是被解除的是什么:suspend 或 ban。
viastring否moderation(在审核中解除)、appeal(申诉被批准)或 expired(暂停期满)。在成员管理中解除则省略。

不发送的情形

  • 暂停期满,但该成员还有另一个生效中的封禁,因此仍处于受限状态。
  • 暂停期满时,事件通常会在几分钟内发出,因为到期检查每 5 分钟进行一次。occurred_at 是发现到期的时间。

member.access_group_added, member.access_group_removed

成员开始或不再持有某个访问组。每个事件只涉及一位成员和一个访问组,所以把订阅从 Silver 换到 Gold 会发送一个 removed 和一个 added。

字段类型是否始终存在说明
member_idhashid是成员 ID。
usernamestring是用户名。
emailstring是邮箱地址。
access_group_idhashid是与 Open API 中的 access_groups[].id 值相同。
access_group_namestring是该访问组当时的名称。对于已删除的访问组,则是删除前的名称。
sourcestring是见下表。
changed_byhashid否仅适用于 manual 和 invitation。
source事件含义changed_by
manualadded, removed管理员在后台授予或移除该管理员
subscriptionadded, removed订阅开始、结束或更换了档位无
invitationadded该成员接受了邀请邀请人
defaultadded该成员进入了默认访问组无
group_deletedremoved该访问组被删除无

不发送的情形

  • 该成员所属的访问组集合实际上没有变化。
  • 成员在注册时被自动加入默认访问组。
  • 变更涉及仍在等待批准的成员。
  • 因账号被删除而清除访问组。
  • 删除访问组或设置默认访问组属于批量变更。事件按成员逐个发送,在操作之后几秒到几分钟到达,而 occurred_at 仍是操作发生的时间。它们的 event_id 格式不同,这正是必须把它当作不透明字符串的原因。

内容

这四个事件中,post_id 都是内容本身的 ID,user_id 是作者,而不是执行操作的人。内容不在任何空间中时,会省略 space_id。私密空间中的内容同样会发送,所以如果你的接收方需要过滤,请按 space_id 过滤。

post.created

发布了一篇帖子或博客文章。

字段类型是否始终存在说明
post_idhashid是帖子 ID。
user_idhashid是作者。
space_idhashid否内容所在的空间。
titlestring是标题。可能是空字符串。
bodystring是Markdown 源文本,最长 500 个字符。可能在某个词或标点处提前截断,且不带省略号。对于博客文章,这是摘要。

发送时机

  • 帖子或博客文章变为公开:直接发布、从审核队列中批准,或到达预定发布时间。

不发送的情形

  • 发布了评论,此时发送的是 comment.created。
  • 博客文章只在首次发布时发送此事件,但取消发布后又重新定时发布的文章可能再次发送。请让你的处理逻辑按 post_id 保持幂等。

post.deleted

删除了一篇帖子或博客文章。

字段类型是否始终存在说明
post_idhashid是帖子 ID。
user_idhashid是作者。
space_idhashid否内容所在的空间。

发送时机

  • 作者删除、管理员删除(单个或批量)、在处理举报时被移除,或封禁清除了该成员的内容。清除一位成员的内容可能产生一批事件。

不发送的情形

  • 博客文章被取消发布并退回草稿,这不属于删除。
  • 删除一篇从未发布过的定时帖子同样会发送此事件,所以收到它并不代表你此前收到过 post.created。
  • 恢复内容没有单独的事件。

comment.created

发布了一条评论。

字段类型是否始终存在说明
post_idhashid是评论自己的 ID。
parent_idhashid是被回复的帖子或评论。
user_idhashid是作者。
space_idhashid否内容所在的空间。
bodystring是规则与 post.created 中的 body 相同。

comment.deleted

删除了一条评论。发送条件与 post.deleted 相同。

字段类型是否始终存在说明
post_idhashid是评论自己的 ID。
parent_idhashid是被回复的帖子或评论。
user_idhashid是作者。
space_idhashid否内容所在的空间。

付款与订阅

这些事件涵盖 Stripe 订阅、一次性空间购买和退款。除非该成员已不存在,否则它们会带有 username 和 email。

应用内购买以及以赠送或兑换码方式获得的订阅不会发送 payment.* 或 subscription.*。如果该套餐关联了访问组,它们会发送 member.access_group_added(source 为 subscription),访问权限结束时发送 member.access_group_removed,你可以用它们来追踪权益的变化。

付费注册时,付款发生在账号创建之前,所以首次付款对应的事件会在账号创建后发送:member.joined、subscription.created,以及在首张账单已支付时的 payment.received。它们可能以任意顺序到达。

payment.received

收到一笔付款。有两种负载形态,可以通过是否存在 invoice_id 来区分。

字段类型订阅账单一次性购买说明
member_idhashid是是成员 ID。
usernamestring通常如此通常如此该成员已不存在时省略。
emailstring通常如此通常如此该成员已不存在时省略。
amount_centsnumber是是已支付的金额。试用开始时的零金额账单为 0。
currencystring是是币种。
invoice_idstring是否Stripe 账单 ID。
subscription_idstring是否Stripe 订阅 ID。
period_startRFC 3339是否这笔付款所购买的周期的开始时间。
period_endRFC 3339是否这笔付款所购买的周期的结束时间。你可以把它用作访问权限的到期时间。
order_idhashid否是订单 ID。
space_idhashid否是所购买的空间。
payment_intent_idstring否是Stripe PaymentIntent ID。
  • 该事件没有 plan_id。
  • 一次性购买会在授予访问权限之后发送此事件,每个订单一次。

subscription.renewed

订阅成功续订。字段与 payment.received 的订阅账单形态相同。

  • 一次续订会同时发送这两个事件,它们的 event_id 值不同。如果你同时订阅了两者,请不要把收入记两次。

payment.failed

账单付款失败。每次失败都会发送,包括 Stripe 每次重试扣款时。

字段类型是否始终存在说明
member_idhashid是成员 ID。
usernamestring否该成员已不存在时省略。
emailstring否该成员已不存在时省略。
invoice_idstring是Stripe 账单 ID。
subscription_idstring是Stripe 订阅 ID。
amount_due_centsnumber是应付金额。
currencystring是币种。
attempt_countnumber是这是该账单的第几次尝试。

subscription.created

在 Stripe 中创建了订阅。这并不表示已经付款;付款请看 payment.received。

字段类型是否始终存在说明
member_idhashid是成员 ID。
usernamestring否该成员已不存在时省略。
emailstring否该成员已不存在时省略。
subscription_idstring是Stripe 订阅 ID。
plan_idhashid是成员订阅套餐的 ID。
statusstring是常见取值:active、trialing 和 incomplete(首张账单尚未结清)。
price_centsnumber是该套餐的单价,折扣之前。
currencystring是币种。
billing_periodstring是monthly 或 annual。
period_startRFC 3339是当前周期的开始时间。
period_endRFC 3339是当前周期的结束时间。

subscription.canceled

订阅实际结束。成员预约在周期结束时取消并不会发送它;周期结束时才会发送。

字段类型是否始终存在说明
member_idhashid是成员 ID。
usernamestring否该成员已不存在时省略。
emailstring否该成员已不存在时省略。
subscription_idstring是Stripe 订阅 ID。
plan_idhashid是成员订阅套餐的 ID。
previous_statusstring是结束之前的状态,例如 active、trialing 或 past_due。
cancelled_atRFC 3339是结束的时间。
  • 一个从未付款就到期的订阅同样会发送此事件,其 previous_status 为 incomplete。该成员从未获得过访问权限,所以不要把它算作流失。
  • 拼写并不统一:事件是 canceled,字段是 cancelled_at,订阅状态值是 cancelled。

refund.issued

已发起退款。订阅账单的退款和一次性购买的退款共用此事件,但字段不同。可以通过是否存在 invoice_id 来区分。

字段类型订阅账单一次性购买说明
member_idhashid是是成员 ID。
usernamestring通常如此通常如此该成员已不存在时省略。
emailstring通常如此通常如此该成员已不存在时省略。
amount_refunded_centsnumber是是截至目前已退款的总额,而不是本次退款的金额。
currencystring是是币种。
refund_statusstringrefunded 或 partially_refundedrefunded退款之后的状态。
is_partialboolean是始终为 false是否为部分退款。
previous_statusstring例如 paid 或 partially_refundedcompleted退款之前的状态。
invoice_idstring是否Stripe 账单 ID。
charge_idstring是,可能是空字符串仅限在 Stripe 中发起的退款Stripe 扣款(charge)ID。
order_idhashid否是订单 ID。
space_idhashid否是所购买的空间。
payment_intent_idstring否是Stripe PaymentIntent ID。
  • 在 Stripe 中对一次性购买做的部分退款不会发送事件,订单和访问权限保持原样。

测试投递

管理后台中的测试 Webhook 会立即发送一个请求,并把结果写入投递日志。已停用的 Webhook 也能测试,并且不会重试。其格式与真实投递相同,包括签名,但以下几点除外。

项目测试投递真实投递
testtrue不存在
event固定为 member.joined,与 Webhook 订阅的事件无关所订阅的事件
data示例数据真实数据
event_id、occurred_at不存在存在

请先检查 test,为 true 时返回 200。否则对 member.joined 的一次测试可能会在你的系统中真的创建出一个成员。

其他渠道

投递日志中还会显示以下来源。它们使用与自定义 Webhook 相同的事件,但格式不同。

渠道请求体签名其他差异
Zapierdata 相同。信封中多了一个布尔值 test,并且没有 webhook_id。仅当连接时设置了签名密钥才有。算法相同。多一个 X-Mateflow-Delivery-Id 请求头。
Slack、Discord只有可读文本,Slack 为 text,Discord 为 content。与 Zapier 相同。多一个 X-Mateflow-Delivery-Id 请求头。没有结构化字段。
自动化规则:触发 Webhook规则中写的模板。仅当规则设置了密钥才有。算法相同。不使用这种信封。由规则决定何时发送。
通过 Open API 回调你的社区

开始构建

在社区管理后台创建一个 Webhook,发送一次测试投递,并在日志中查看。

14 天免费试用 · 之后可用 Free 套餐,最多 100 名成员 · 无需信用卡