后端表单提交 webhook:实时推送与验签

表单提交 webhook:实时推送与验签

站点表单一有提交就 POST 到你的 https 地址:通过 MCP 注册,HMAC-SHA256 签名,失败自动重试,可以用另一个 Creght 项目的 Func 接收并按提交 id 幂等入库。

站点表单每收到一条提交,Creght 立刻向你注册的 https 地址发一次 POST,请求用 HMAC-SHA256 签名。接收方可以是任何 https 服务,也可以是另一个 Creght 项目的 Func。

注册 webhook

webhook 通过 Creght MCP(https://creght.cn/api/mcp)管理,编辑器里暂时没有界面。AI 助手或本地脚本连上 MCP 后调用下面四个工具:

工具参数返回
form_webhook_createproject_id;url(必须 https);form_ids(可选,表单 id 或 key,不填是项目下全部表单,包括以后新建的)webhook(id、url、form_ids、secret_prefix、created_at)和 secret
form_webhook_listproject_id项目下的 webhook,每个带最近 10 次投递结果
form_webhook_deleteproject_id、webhook_id{ deleted: true },等待重试的投递一并放弃
form_webhook_testproject_id、webhook_id同步发一条测试事件,返回这次的状态码、错误和响应开头

secret 只在创建时返回一次,之后只能看到开头几位(secret_prefix)用来核对。丢了就删掉重建。每个项目最多 10 个 webhook。

请求格式

每次投递是一个 POST,Content-Type: application/json:

{
  "event": "form.submitted",
  "event_id": "1287",
  "webhook_id": "12",
  "project_id": "p9k3n5y5hbbm",
  "submission": {
    "id": "3f9k2x",
    "form_id": "8a1b2c",
    "form_key": "contact",
    "form_name": "联系我们",
    "created_at": "2026-09-26T11:56:57.123456Z",
    "form_url": "https://example.com/contact?utm_source=google&utm_campaign=q3",
    "fields": { "name": "张三", "email": "z@x.com", "message": "想询价 500 件" },
    "ua": "Mozilla/5.0 …",
    "country": "US"
  }
}
字段说明
eventform.submitted(真实提交)或 form.test(form_webhook_test 发的测试事件)
event_id这次投递的 id,重试时不变
submission.id提交 id,按它去重
submission.form_url提交所在页面的完整地址,含 query,来源参数(utm_*)在这里
submission.fields字段 key → 字符串值。多选用逗号连接,文件是可访问的 URL
submission.country按提交者 IP 解析出的国家代码(ISO 3166-1 两位),解析不了时没有这个字段。IP 本身不发送

请求头:

请求头说明
X-Creght-Event同 event
X-Creght-Event-Id同 event_id
X-Creght-Webhook-Id同 webhook_id
X-Creght-Timestamp发送时间,unix 秒
X-Creght-Signaturev1=<hex>,见下一节

验证签名

hex = HMAC-SHA256(secret, "<X-Creght-Timestamp>.<原始请求体>")
X-Creght-Signature = "v1=" + hex
  • 对原始字节验签。先把请求体解析成 JSON 再序列化回去,字节可能变,签名就对不上。
  • 时间戳也在签名里,改了就对不上。再拒收超过 5 分钟的请求,就能防止别人重放截获的旧请求。
  • 在 Node 等环境里比较签名时用常量时间比较(如 crypto.timingSafeEqual)。

用 Creght Func 接收

接收方可以是 另一个 Creght 项目的 Func,地址是 https://<站点域名>/func/<key>。比如站点项目的询盘推到运营后台项目,由那边的 Func 写进数据表。请求头用 ctx.request.headers.get() 读,原始请求体用 ctx.request.arrayBuffer() 读,见 读取请求。

把 secret 存到接收项目的环境变量(后端 → 环境变量),例如 CREGHT_WEBHOOK_SECRET:

export async function main(input, ctx) {
  const ts = ctx.request.headers.get("x-creght-timestamp") || ""
  const sig = ctx.request.headers.get("x-creght-signature") || ""
  const body = new Uint8Array(await ctx.request.arrayBuffer())

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    ctx.response.status(400)
    return { error: "stale request" }
  }

  const prefix = new TextEncoder().encode(ts + ".")
  const msg = new Uint8Array(prefix.length + body.length)
  msg.set(prefix)
  msg.set(body, prefix.length)
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(process.env.CREGHT_WEBHOOK_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  )
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, msg))
  const hex = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("")
  if (sig !== "v1=" + hex) {
    ctx.response.status(401)
    return { error: "bad signature" }
  }

  const evt = JSON.parse(new TextDecoder().decode(body))
  if (evt.event === "form.test") return { ok: true }

  const s = evt.submission
  const existing = await ctx.db.query("leads", { where: { submission_id: s.id }, limit: 1 })
  if (existing.total === 0) {
    await ctx.db.insert("leads", {
      submission_id: s.id,
      form: s.form_key,
      name: s.fields.name,
      email: s.fields.email,
      message: s.fields.message,
      page: s.form_url,
      country: s.country,
      submitted_at: s.created_at,
    })
  }
  return { ok: true }
}
  • Func 地址默认不要求登录,验签就是唯一的门,不要省。
  • 每次投递消耗接收项目一次 Func 调用额度。
  • 接收项目需要有能访问的站点域名。

重试与幂等

  • 接收方返回 2xx 算成功。每次请求超时 10 秒。
  • 否则按 30 秒、2 分钟、10 分钟、1 小时、6 小时重试,最多 6 次。返回 410 Gone 立即停止重试。
  • 4xx 也会重试:接收方配置写错了,改好后后面的重试还能收到。
  • 同一条提交可能收到不止一次(比如你处理成功了但响应超时),按 submission.id 幂等处理。
  • 不跟随重定向。地址改了请删掉重建。

排障

  1. 注册后先调 form_webhook_test,看返回的 status_code:0 表示没拿到响应(连不上、超时、地址被拒),401 通常是验签不过。
  2. 真实提交没收到时调 form_webhook_list,看 recent_deliveries 里的 status(pending 表示在等重试)、attempts、error 和 response。投递记录保留 30 天。

边界

  • 只允许 https 地址,不能指向内网、回环或云厂商元数据地址。
  • 每个项目最多 10 个 webhook。
  • 只覆盖 Creght 站点的表单,旧版可视化编辑器的表单不会触发。
  • 暂时只能通过 MCP 管理,编辑器里没有界面,CLI 也没有对应命令。

Render diagnostics