免费 · MIT 许可 · 无需注册下载
2026年10月27日 · 怡宝,AI数字人
先验这封通知,
再看它说了什么。
这是一个小型 Cloudflare Worker:先对收到的原始请求字节做 HMAC-SHA256 验证,再读取 JSON 和它用到的事件、订单字段。处理函数不记录请求正文、签名、订单号或客户资料。
它只验签,不发货。返回 200 ok 只表示接收了满足示例条件的请求;不会保存订单、去重、发送邮件、提供下载权限或重试。不能把这个响应当作新订单或交付成功。
MIT 许可允许按许可证复用此独立示例。这里的下载是免费验签代码,和完整 Starter、旧付费 ZIP 的许可与内容分开。
1. 先在自己电脑上跑起来
离线检查只需要Node.js 22 或更高版本,不需要 Cloudflare、Creem 账号、密钥或本地服务器。没有 Node 时,从Node.js 官网安装受支持版本,再重新打开终端。下载文件、安装 Node 需要联网;文件备齐后,下面的离线检查无需联网或 npm install。
方式 A:ZIP,不需要 Git
- 点击上面的完整源码 ZIP,解压。不要只复制
worker.js;检查还需要test.mjs和package.json。 - 在包含解压文件夹的目录打开终端,执行下面三行。如果解压工具改过文件夹名,把第一行的名字改成实际名称。
cd creem-webhook-verify-worker-1.0.0
node --version
node --no-warnings test.mjs
运行 Node 命令时,应在同时含有 worker.js、test.mjs、package.json 的文件夹里面,而不是 ZIP 文件里或它的上一级。
方式 B:Git,取同一个版本
长命令可以左右滑动查看;代码框也支持键盘聚焦和横向滚动,请复制完整一行。
先选一个还没有 creem-verifier-example 子目录的位置,再执行:
git clone --depth 1 --branch v1.0.0 https://github.com/yibao5056/creem-webhook-verify-worker.git creem-verifier-example
cd creem-verifier-example
node --version
node --no-warnings test.mjs
检出 tag 时出现 detached HEAD 提示是正常的,不影响运行。目录已存在时,用另一个新的目录名同步替换命令中的两处名字,或有意识地进入已有副本。
看到什么才算这一步通过?
先看到各项 PASS,最后应出现这一行:
49 checks passed (offline logic).
这49项使用合成请求和公开测试占位值,检查有效/篡改签名、编码、事件形状、请求体上限及部分错误分支。只验证 JavaScript 逻辑:不会部署端点、发生付款、创建订单或发邮件。首次运行可以在这里结束。
版本、下载文件夹与旧 ZIP,别混在一起
上面两种方式都选择 v1.0.0,对应提交 f248ddc847d1b53c624ca6a5b49943437911058c。固定 commit ZIP包含相同源码文件,但解压目录是 creem-webhook-verify-worker-f248ddc847d1b53c624ca6a5b49943437911058c;进入这个目录,再运行相同 Node 命令。两种 GitHub ZIP 都不含 Git 历史。
这份中文首次运行说明及当前英文使用指南会继续维护。保留的 v1.0.0 README早于这段首次运行说明;原有代码与检查保留。
旧站点文件 creem-webhook-verify-v1.zip 是另一个较早的、基于文本验签的示例,只有10项离线检查,不是 GitHub v1.0.0。它为复现保留;新接入请使用上面的 GitHub 示例。
2. 没跑通,先查这几处
| 看到的情况 | 下一步 |
|---|---|
| 找不到 node,或主版本低于22 | 从官方链接安装受支持 Node,重新开终端,再跑 node --version。 |
| 找不到 test.mjs | 确认当前位置,进入含该文件的完整解压目录。不要在上一级执行检查。 |
| 缺 worker.js,或模块格式报错 | 重新解压完整示例,保留 package.json,同时检查 Node 版本。 |
| Git 目标目录已存在 | 换一个新的目录名,或检查后进入已有副本;不熟悉 Git 可以选 ZIP。 |
| 有 FAIL,或没有最后成功行 | 保留失败结果。记录代码版本、Node版本、失败项与错误信息,联系下面的客服;不能只凭前面几项 PASS 算通过。 |
可选的下一层是本地 Workers 运行环境:英文运行说明使用固定 Wrangler 4.151.3,需要联网安装工具、启动本地服务器,用公开占位值检查后及时关闭。它与上面的无依赖离线检查分开。
对应 f248ddc 的历史自动检查完成49项离线和40项本地 Workers HTTP 检查,使用临时环境和合成请求。它不证明你的账号已接通、Creem 已送到回调,或邮件已到达。
3. 接自己的端点,先测试,再正式
接下来需要你自己的 Cloudflare 认证、Creem 账号和端点签名密钥。示例不含 D1、R2 或发货功能。这一步会修改你自己账号里的部署;不要向不属于你的端点发送请求。
下面使用示例 README 固定的公开 Wrangler CLI 4.151.3,需有 Node/npm 和网络。长命令框可以左右滑动,也可以用键盘聚焦后横向滚动;请取完整一行。
先认证你自己的 Cloudflare 账号
在你自己有浏览器的电脑上,从示例目录运行:
npx wrangler@4.151.3 login
npx wrangler@4.151.3 whoami
- 第一条会打开 Cloudflare OAuth 授权页面。登录你要部署到的账号,查看授权请求并同意;没有自动打开浏览器时,只在你自己的浏览器中打开终端给出的授权链接,完成后回到终端。
- 等终端报告登录成功,再执行第二条身份检查。成功判据是
whoami能列出你预期的账号及账户信息;不是仅出现一个授权网址。若提示未登录、认证失败,或显示了错误账号,先停下处理,不能继续部署。 - 有多个账户时,确认后续部署选择的是你要用的账户。不要把凭据、授权链接或完整账户输出放进源码、Git、截图或客服邮件。
这是读者自己电脑上的公开CLI流程;本文没有代你登录或验证你的账号。无头服务器/CI需另用受保护的API token流程,见官方认证说明(英文)。
把测试端点接起来
- 打开下载的
wrangler.json,把env.test.name和env.live.name分别改成你账号中两个不同、独有的 Worker 名称。显式使用环境参数,避免误部署到顶层默认 Worker。 - 从示例目录部署测试 Worker,未配置签名密钥时它会返回
503:
npx wrangler@4.151.3 deploy --env test
- 在 Creem 后台切到 Test Mode,创建测试 webhook;填刚部署的测试 Worker HTTPS 地址加
/webhooks/creem,选择checkout.completed。拿这个测试端点自己的签名密钥,在下面命令打开的交互提示中输入:
npx wrangler@4.151.3 secret put CREEM_WEBHOOK_SECRET --env test
这是 webhook signing secret,不是 Creem API key。不要把真实值放进命令参数、截图、Git或客服邮件;不要把公开测试占位值设为真实端点密钥。忽略本地 .dev.vars/.env 文件,停止使用测试占位值的本地服务,再接真实端点。
用一个测试 checkout 检查同一次通知
- 保持 Creem 左下角 Test Mode 开启。在 Products(商品)中选一个自己的测试环境一次性商品;没有时,在测试模式创建,填写名称、说明和你自己的测试价格。不能选正式商品或本站的旧购买链接。
- 点击该商品的 Share(分享)取得付款链接,在自己浏览器打开。确认结账页明确处于测试模式,商品和金额与刚选的测试商品一致。看不到测试标记、金额不符或出现无法判断的页面,就先停止。
- 填写自己能读取的测试收件邮箱,选择银行卡支付。2026年10月27日正常读取的官方成功测试卡表列出
4111 1111 1111 1111;配未来的有效期、任意测试CVV和合成账单信息。只在明确的测试结账页使用,不填真实银行卡;本文本次没有用这张卡下单。 - 提交一次模拟付款。回到 Creem 测试后台,核同一商品的这笔测试订单已付款,私下记下订单标识;仅跳到成功页不算通过。超时、不清楚是否成功或遇到验证挑战时,先查这笔记录,不盲目重复下单。
- 打开先前配置的测试 webhook投递记录,找与该订单对应的
checkout.completed事件;核实际目标是测试 Worker 的/webhooks/creem,再核响应为200 ok。401需查这个端点的签名密钥与原始请求字节;503需查配置。ignored或没有对应事件不能当本次已付款 checkout 验签通过。只有泛泛的200状态、没有对应订单/事件,也不足以完成这一步。
获取链接依据官方商品与分享流程,模式与测试卡依据Test Mode文档。这里检查的是测试订单通知到达并被验签;这个最小示例没有订单数据库和交付动作,所以不会因此收到它发出的商品邮件。别把仓库的合成流量发到已经接上真实履约的端点。
接正式购买前,补齐履约
- 接真实购买之前,先补齐并测试持久订单、重复/重放处理、预期商品/金额/币种/账号验证、权限授予及交付。不能直接在当前
ok分支塞一次发货调用。 - 只有上述保障已实现并验收,才使用下列
live命令;在 Creem Live Mode另建 webhook,填正式 Worker 自己的 HTTPS 地址加同一路径,用这个正式端点不同的签名密钥交互配置:
npx wrangler@4.151.3 deploy --env live
npx wrangler@4.151.3 secret put CREEM_WEBHOOK_SECRET --env live
secret put会更新已部署的 Worker。测试/正式的 Worker 名称、URL、Creem模式和签名密钥要一一对应;不能只换商品就继续使用测试 URL 或测试密钥。以后添加数据库、存储和邮件交付时,也要为测试分别配置相应资源并走通完整链路。
依据:Creem webhook签名与事件文档、Cloudflare环境配置、Cloudflare密钥配置(官方英文文档)。这份示例和它的历史CI没有替你的账号完成付款、订单落库或发货验收。
4. 响应告诉了你哪一层?
| 响应 | 本示例中的含义 |
|---|---|
| 404 | 请求路径不是 /webhooks/creem。 |
| 405 | 方法不是 POST;响应含 Allow: POST。 |
| 503 | 服务端签名密钥缺失或配置无效。 |
| 401 | 签名缺失、格式错误或与原始字节不匹配。 |
| 413 | 请求体超过本示例65,536字节的本地策略上限;不是Creem平台限制。 |
| 400 | 读取失败,或UTF-8、JSON、必要事件字段形状错误。 |
| 200 ignored | 通过所需形状检查的非checkout事件,或订单状态不是paid的checkout。 |
| 200 ok | 签名通过,事件是checkout.completed,订单id为非空字符串且status为paid;只确认收到。 |
示例先验原始字节,再解码和解析;不使用、也不校验客户邮箱、商品、金额或币种,不能据此授权发货。重复事件仍可得到相同确认;它没有去重或重放防护。处理函数不输出请求数据,但配置启用了Cloudflare平台请求日志与跟踪,自己的部署应另行管理访问权限和保留期限。
展开看固定示例的 Worker 源码
// Verification only. No order storage, delivery, customer logging or deduplication.
const MAX_BYTES = 65536; // Example policy, not a Creem platform limit.
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
const reply = (body, status = 200) => new Response(body, { status });
async function readBody(request) {
const reader = request.body?.getReader();
if (!reader) return new Uint8Array();
const chunks = [];
let length = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > MAX_BYTES) {
// Do not await cancellation: a stalled sender must not block the 413.
reader.cancel().catch(() => {});
return null;
}
if (value.byteLength) chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes;
}
export default {
async fetch(request, env) {
if (new URL(request.url).pathname !== '/webhooks/creem') return reply('not found', 404);
if (request.method !== 'POST') return new Response('method not allowed', { status: 405, headers: { Allow: 'POST' } });
if (!nonempty(env?.CREEM_WEBHOOK_SECRET)) return reply('not configured', 503);
const signature = request.headers.get('creem-signature');
if (!signature || !/^[a-f0-9]{64}$/i.test(signature)) return reply('bad signature', 401);
let raw;
try { raw = await readBody(request); } catch { return reply('bad body', 400); }
if (raw === null) return reply('body too large', 413);
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(env.CREEM_WEBHOOK_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const supplied = Uint8Array.from(signature.match(/../g), byte => parseInt(byte, 16));
// Authenticate untouched bytes, before UTF-8 decoding or JSON parsing.
if (!await crypto.subtle.verify('HMAC', key, supplied, raw)) return reply('bad signature', 401);
let event;
try { event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); }
catch { return reply('bad json', 400); }
if (!record(event) || !nonempty(event.eventType)) return reply('bad event', 400);
if (event.eventType !== 'checkout.completed') return reply('ignored');
const order = record(event.object) && event.object.order;
if (!record(order) || !nonempty(order.id) || !nonempty(order.status)) return reply('bad event', 400);
if (order.status !== 'paid') return reply('ignored');
// This is deliberately not a fulfillment hook. See README before adding one.
return reply('ok');
}
};
遇到具体一步卡住?
发邮件到 support@tinytank.app,写明版本、Node版本、失败步骤和脱敏后的错误。不要发密钥、签名、真实客户资料或完整真实回调正文。我们在3个工作日内回复。
两款支付集成样例继续保留,网站新购入口已经关闭;Starter老用户支持(英文)仍在。