设计实践

给 Framer 网站的“预约包场”表单接一个自己的 Webhook 接收端

给 Framer 网站的“预约包场”表单接一个自己的 Webhook 接收端:校验 Framer 签名,丢弃伪造请求;按提交 ID 去重,避免 Framer 重试造成重复记录;把提交写入 JSON Lines 文件,后续可以接邮件或表格。

BINBHO · 图文教程

给 Framer 网站的“预约包场”表单接一个自己的 Webhook 接收端:校验 Framer 签名,丢弃伪造请求;按提交 ID 去重,避免 Framer 重试造成重复记录;把提交写入 JSON Lines 文件,后续可以接邮件或表格。

先看清成品

本机运行:2 个 node --test 测试通过(签名计算、接收 + 去重 + 拒绝伪造);启动服务后用 curl 发送正确签名的请求返回 ok 200,错误签名返回 bad signature 401,并写入了一行记录。没有接入真实 Framer 网站,Framer 端的设置界面以官方帮助文档为准。

注意:Framer 的计划、价格和自定义域名、CMS 条目数等限额以官方价格页为准,本文不列具体数字。只使用你有权使用的图片、字体和文案;不要共享账号。

隐私提示:表单收集姓名、邮箱、电话等个人信息,请在网站上说明用途,只收必要字段,存储文件要限制访问权限并定期清理。

做完之后你会得到什么?

成果 说明
接收端 POST /framer-form,Node.js 无依赖
签名校验 HMAC-SHA256,常量时间比较
去重 按 Framer-Webhook-Submission-Id
存储 submissions.jsonl 每行一条

需要准备哪些环境?

项目 要求 说明
Node.js 18 以上(本机 v22) 用内置 fetch 和 node:test
公网 https 地址 Framer 要求 https:// 部署到服务器、Cloud Run 等
Framer 表单 已发布的网站 Webhook 只在发布后的网站生效

文件结构

26-framer-C-src/
├── package.json
├── verify.js
├── server.js
└── webhook.test.mjs

第 1 步:签名规则

Framer 帮助文档说明:请求头带 Framer-Signature(格式 sha256= + 64 位十六进制)和 Framer-Webhook-Submission-Id;签名是用你设置的密钥,对原始请求体字节再接上提交 ID 做 HMAC-SHA256。必须对原始字节计算,先 JSON.parse 再序列化会改变空格和顺序,签名就对不上。

verify.js:

import crypto from "node:crypto"

// Framer 的签名:HMAC-SHA256(secret, 原始请求体字节 + 提交 ID),格式 "sha256=<64 位十六进制>"
export function sign(secret, rawBody, submissionId) {
  return "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).update(submissionId).digest("hex")
}

export function isValid(secret, rawBody, submissionId, signature) {
  if (typeof signature !== "string" || signature.length !== 71 || !signature.startsWith("sha256=")) return false
  if (!submissionId) return false
  const expected = sign(secret, rawBody, submissionId)
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}

第 2 步:接收端

server.js:

import http from "node:http"
import fs from "node:fs"
import { isValid } from "./verify.js"

const SECRET = process.env.FRAMER_WEBHOOK_SECRET
const OUT = process.env.OUT_FILE || "submissions.jsonl"
const MAX_BODY = 32 * 1024
if (!SECRET || SECRET.length < 32) {
  console.error("请设置 FRAMER_WEBHOOK_SECRET(至少 32 个字符,与 Framer 中填写的一致)")
  process.exit(1)
}

const seen = new Set()  // 简单去重;多实例部署请换成数据库唯一索引
if (fs.existsSync(OUT)) for (const l of fs.readFileSync(OUT, "utf8").split("\n")) if (l) seen.add(JSON.parse(l).id)

export const server = http.createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/framer-form") { res.writeHead(404).end(); return }
  const chunks = []; let size = 0
  req.on("data", (c) => { size += c.length; if (size > MAX_BODY) req.destroy(); else chunks.push(c) })
  req.on("end", () => {
    const raw = Buffer.concat(chunks)
    const id = req.headers["framer-webhook-submission-id"]
    if (!isValid(SECRET, raw, id, req.headers["framer-signature"])) { res.writeHead(401).end("bad signature"); return }
    if (seen.has(id)) { res.writeHead(200).end("duplicate"); return }  // Framer 重试时直接确认
    let data
    try { data = JSON.parse(raw) } catch { res.writeHead(400).end("bad json"); return }
    seen.add(id)
    fs.appendFileSync(OUT, JSON.stringify({ id, at: new Date().toISOString(), data }) + "\n")
    res.writeHead(200).end("ok")   // 先确认,慢操作(发邮件、写 CRM)放到后台
  })
})

if (process.argv[1] === new URL(import.meta.url).pathname) {
  server.listen(process.env.PORT || 3000, () => console.log("listening", server.address().port))
}

要点:

  • 必须返回 2xx,否则 Framer 会重试(最多 5 次);3xx 重定向不会被跟随。
  • 重复的提交 ID 也返回 200,告诉 Framer “已收到”。
  • 限制请求体大小,防止被大请求拖垮。

第 3 步:测试

webhook.test.mjs:

import test from "node:test"
import assert from "node:assert"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { sign, isValid } from "./verify.js"

const SECRET = "x".repeat(32)
process.env.FRAMER_WEBHOOK_SECRET = SECRET
process.env.OUT_FILE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "fw-")), "s.jsonl")
const { server } = await import("./server.js")

async function post(body, id, sig) {
  await new Promise((r) => server.listening ? r() : server.listen(0, r))
  const res = await fetch(`http://127.0.0.1:${server.address().port}/framer-form`, {
    method: "POST", body, headers: { "content-type": "application/json", "framer-webhook-submission-id": id, "framer-signature": sig } })
  return [res.status, await res.text()]
}

test("签名计算", () => {
  const s = sign(SECRET, Buffer.from("{}"), "abc"); assert.strictEqual(s.length, 71); assert.ok(isValid(SECRET, Buffer.from("{}"), "abc", s))
  assert.ok(!isValid(SECRET, Buffer.from("{ }"), "abc", s))  // 请求体差一个空格也不行
})

test("收到、去重、拒绝伪造", async () => {
  const body = JSON.stringify({ name: "李雷", email: "li@example.com", message: "想预约包场" })
  const sig = sign(SECRET, Buffer.from(body), "sub-1")
  assert.deepStrictEqual(await post(body, "sub-1", sig), [200, "ok"])
  assert.deepStrictEqual(await post(body, "sub-1", sig), [200, "duplicate"])
  assert.strictEqual((await post(body, "sub-2", sig))[0], 401)   // 换了 ID,旧签名无效
  const lines = fs.readFileSync(process.env.OUT_FILE, "utf8").trim().split("\n")
  assert.strictEqual(lines.length, 1); assert.strictEqual(JSON.parse(lines[0]).data.name, "李雷")
  server.close()
})
npm test

检查结果:# pass 2、# fail 0。

第 4 步:本地手动验证

export FRAMER_WEBHOOK_SECRET=(至少 32 个字符)
PORT=3099 node server.js &
BODY='{"name":"韩梅梅","email":"han@example.com"}'
SIG=$(node -e 'import("./verify.js").then(m=>console.log(m.sign(process.env.FRAMER_WEBHOOK_SECRET,Buffer.from(process.argv[1]),"0fbbb90e-564b-4870-ac8b-78f9bc4e44a6")))' "$BODY")
curl -s -w ' %{http_code}\n' -X POST localhost:3099/framer-form \
  -H "framer-webhook-submission-id: 0fbbb90e-564b-4870-ac8b-78f9bc4e44a6" -H "framer-signature: $SIG" -d "$BODY"

本机实际输出:

ok 200
bad signature 401        ← 用 sha256=bad 再发一次
{"id":"0fbbb90e-564b-4870-ac8b-78f9bc4e44a6","at":"2026-10-21T06:49:17.416Z","data":{"name":"韩梅梅","email":"han@example.com"}}

第 5 步:在 Framer 中连接(本文未执行)

  1. 部署接收端,得到 https://你的域名/framer-form。
  2. 在 Framer 画布选中表单,右侧 Send To 旁点 Add… → Webhook,填入地址。
  3. 设置密钥(至少 32 个字符),与服务器的 FRAMER_WEBHOOK_SECRET 相同。密钥设置后无法再查看,先存进密码管理器。
  4. 发布网站,提交一次表单,检查 submissions.jsonl。

调试示例

测试“签名计算”中,请求体从 {} 改成 { }(多一个空格),签名校验就失败。这就是必须用原始字节的原因;如果你用 Express,记得用 express.raw() 而不是 express.json() 读取这个路由的请求体。

出错了怎么办?

现象 原因 解决
一直 401 密钥不一致,或对解析后的 JSON 计算签名 核对密钥,用原始请求体
同一条提交出现多次 没去重,或处理太慢被重试 按提交 ID 去重,先返回 200
Framer 提示无法保存地址 不是 https 配置 HTTPS
预览里提交没有到达 Webhook 只在发布后的网站生效 发布后测试
多台服务器重复写入 内存去重不共享 改用数据库唯一索引

模板规则

  • 对外开放的 Webhook 一律校验签名。
  • 先确认、后处理;按 ID 去重。
  • 个人信息文件限制权限并定期清理。

项目资源

用途 地址
Framer 表单连接 Webhook(含签名说明) https://www.framer.com/help/articles/framer-form-webhook-setup/
Node.js crypto https://nodejs.org/api/crypto.html
Framer 价格 https://www.framer.com/pricing

签名校验和去重都只有十几行代码,却决定了这个接口能不能放心挂在公网上。Framer 负责好看的表单,数据去哪由你自己掌控。