给 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 中连接(本文未执行)
- 部署接收端,得到
https://你的域名/framer-form。 - 在 Framer 画布选中表单,右侧 Send To 旁点 Add… → Webhook,填入地址。
- 设置密钥(至少 32 个字符),与服务器的
FRAMER_WEBHOOK_SECRET相同。密钥设置后无法再查看,先存进密码管理器。 - 发布网站,提交一次表单,检查
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 负责好看的表单,数据去哪由你自己掌控。