Claude 接入 Creem 实战踩坑手册
为 handdraw.store 接入 Creem 订阅,代码跑通了,合规审核却打回 8 条整改意见,含 1 条硬门槛。记录 test/live 完全隔离、webhook 验签四个细节、moderation 来回改两次的弯路,以及切 live 上线清单。

最近,为 handdraw.store 这个项目接入 Creem 的过程中,踩了一些坑,在审核过程,全靠 Claude 来帮助修改和应对审核策略。最终通过审核后,总结出这篇踩坑手册,以后可以将手册直接丢给 Claude,完成其他工程的 Creem 接入。
我们踩坑的真实顺序
| 阶段 | 做了什么 | 结果 |
|---|---|---|
| 决策 | 从 Lemon Squeezy 改用 Creem | 只需换 env + 一个 lib 文件,业务代码无感 |
| 拿凭据 | 注册 → 建订阅产品 → 拿 API Key → 配 webhook | 拿到 3 个 CREEM_* 变量(test 模式) |
| 写代码 | createCheckout + webhook 验签 + 计划同步 | 本地端到端跑通(commit 8200d01) |
| 本地联调 | 自签 HMAC 打自己的 webhook 端点 | 验签通过 → 升 pro/800 → 取消回落 free/20 |
| 真实 checkout | 用 test key 调 /v1/checkouts | 拿到真实测试结账链接 https://creem.io/test/checkout/prod_... |
| 提审被打回 | 提交 onboarding 资料 | 8 条整改意见,含 1 条硬门槛 |
| 整改 | 绑自有域名 + 3 份政策页 + 同域客服邮箱 + 接 Moderation API | commit 1317c2b |
| 反复摇摆 | moderation 先设为 opt-in(7794cba),3 天后改回强制(7a5035e) | 走了弯路,见下文 moderation 一节 |
| 价格核对 | 页面写 $6.99,用 API 核对 Creem 上的产品价格 | 一致(699 分),但仍是 test 模式产品 |
坑 1:test/live 双环境是完全隔离的
切换到正式收款,需要重建而不是切换:
| 项 | test | live |
|---|---|---|
| API 域名 | test-api.creem.io | api.creem.io |
| API Key | creem_test_... | creem_... |
| 产品 | 测试产品 id | 必须重新创建,得到新的 CREEM_PRODUCT_ID |
| Webhook | 测试端点 secret | 必须重新配置端点,得到新的 CREEM_WEBHOOK_SECRET |

让这件事不痛的做法只有一个:不要把域名写死,也不要引入 CREEM_ENV 之类的第二个开关,从 API Key 前缀推导域名。切 live 时一行代码都不用改,只换环境变量 + 重新部署。
function apiBase() {
if (process.env.CREEM_API_URL) return process.env.CREEM_API_URL.replace(/\/$/, ""); // 逃生口
return apiKey().startsWith("creem_test_")
? "https://test-api.creem.io"
: "https://api.creem.io";
}
测试模式的 Moderation 审核明显偏宽松。我们用露骨的英文 prompt 打 test 端点,返回的是 allow。这是 sandbox 行为,不代表你的拦截逻辑没生效,只有换 live key 后 Creem 才会真正判定。所以「测试模式下审核没拦住」不是 bug,不要为此去改判定逻辑。
文档里出现的 403 insufficient permissions,实际含义通常是用错了环境的 key(拿 test key 打 api.creem.io),或者访问了不属于该商店的资源。先查这个,再怀疑权限配置。
坑 2:Webhook 验签的四个细节
本项目的实现(src/lib/creem.ts + src/app/api/webhooks/creem/route.ts),四个点缺一个就验不过:
- 必须读原始文本 body:
await request.text(),然后用这个字符串计算 HMAC。一旦先request.json()再JSON.stringify回去,字节序/空格变了,签名永远不匹配。 - 请求头名是
creem-signature,值是 hex 字符串——不是 base64,没有t=,v1=这种 Stripe 式的复合格式。 - timing-safe 比较前先比长度:
timingSafeEqual在长度不等时会抛异常,必须先判断再比。 - Next.js 路由要声明
export const runtime = "nodejs",因为用了node:crypto。

export function verifyWebhookSignature(rawBody: string, signature: string | null): boolean {
const secret = process.env.CREEM_WEBHOOK_SECRET;
if (!secret || !signature) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length) return false; // ← 必须,否则 timingSafeEqual 抛错
return timingSafeEqual(a, b);
}
本地联调 webhook 的实际做法:不需要 ngrok/隧道。自己按同样算法签一个 payload 打本地端点即可,能覆盖「验签通过 → 升级 → 取消回落」全链路:
# 用 node 生成签名,再 curl 打本地端点(--data-binary 保证 body 字节不变)
SIG=$(node -e 'const {createHmac}=require("node:crypto");
const body=require("fs").readFileSync("/tmp/payload.json","utf8");
process.stdout.write(createHmac("sha256",process.env.CREEM_WEBHOOK_SECRET).update(body).digest("hex"))')
curl -s -X POST -H "Content-Type: application/json" -H "creem-signature: $SIG" \
--data-binary @/tmp/payload.json http://localhost:3000/api/webhooks/creem -w " [%{http_code}]\n"
回归用例至少两条:错误签名必须返回 401;正确签名 + subscription.canceled 必须把 plan 打回 free 并恢复免费配额。
坑 3:用户映射与事件实体形状不统一
metadata 是你唯一的用户映射通道。下单时把自己的 user id 塞进 metadata,webhook 里从 object.metadata.userId 取回:
// checkout 时
body: JSON.stringify({
product_id: productId,
request_id: `checkout_${userId}_${Date.now()}`, // 幂等标识
success_url: successUrl,
...(email ? { customer: { email } } : {}),
metadata: { userId } // ← 唯一的用户映射通道
})
实体形状会变,取 id 要防御性处理:
subscription.*事件里,订阅 id 就是object.id;checkout.completed里,订阅 id 在object.subscription,而这个字段可能是字符串也可能是{ id }对象;object.customer同样可能是字符串或对象。
所以需要一个 asId() 归一化函数:
function asId(v: { id?: string } | string | null | undefined): string | null {
if (!v) return null;
return typeof v === "string" ? v : v.id ?? null;
}
const subscriptionId = type.startsWith("subscription") ? entity.id ?? null : asId(entity.subscription);
无法映射到用户、或事件不在关心列表里时,返回 200 {received:true} 而不是报错,否则 Creem 会当成投递失败反复重试。
[建议] 两点本项目没做但值得做:
- 幂等:Creem 会重投事件。本项目靠「更新 plan 本身是幂等的」侥幸没出问题,但如果你要写「赠送积分/发邮件」这类非幂等副作用,必须用事件 id 或
request_id去重。 - 回跳与 webhook 的竞态:
success_url回跳到前端时,webhook 可能还没到,用户会看到自己还是 free。回跳后应主动重新拉一次 session/plan,或在成功页做短轮询。
合规审核:这是最大的坑
代码全部跑通、测试结账链接都拿到了之后,提交 onboarding 资料被合规团队打回,一次给了 8 条整改意见。原文要点与对应修法:
| # | Creem 的要求 | 谁能修 | 怎么修 |
|---|---|---|---|
| 1 | 网站不能挂在 *.vercel.app 部署域名上,必须绑生产自有域名 | 只有用户 | 买域名 + Vercel 绑定;同时把 NEXT_PUBLIC_APP_URL 改成正式域名 |
| 2 | 必须有公开可访问的 Privacy Policy 链接 | AI | 新增 /privacy 页 + 页脚常驻链接 |
| 3 | 必须有公开可访问的 Terms of Service 链接 | AI | 新增 /terms 页 + 页脚链接 |
| 4 | 必须有与网站同域的客服邮箱,且在站内可见 | 两者 | 代码里改成 support@<你的域名>;用户去开通该邮箱(转发到常用邮箱即可) |
| 5 | AI 图像产品必须有 Acceptable Use Policy(或等效章节),覆盖禁止生成的内容 | AI | 新增 /acceptable-use 页 |
| 6 | 公开政策必须明确禁止 NSFW / 成人 / 色情 / 性暗示内容生成 | AI | 在 Terms + AUP 里写明确条款 |
| 7 | 强制集成 Content Moderation API,覆盖所有 prompt 入口,且要能看出「已集成并测试过」 | AI | 见「Content Moderation API」一节 |
| 8 | 该品类欺诈率高,只接纳「已成熟」的 AI 生成产品;否则回邮件提供既往支付流水 + 拒付率证明 | 只有用户 | 硬门槛,见下 |
第 8 条是硬门槛,要提前告诉用户
这一条与代码无关:即使 1–7 条全部改好,全新产品(没有历史流水)也未必能过。 接入前就应该让用户先决定:
- 有别的渠道的历史流水 / 拒付率数据 → 回邮件提供证明;
- 完全从零 → 考虑先用其他渠道跑出流水,或换 MoR 供应商,再回来提审。
正确的顺序是:在动手写 Creem 代码之前就把这一条摆出来问清楚,而不是等 8 条打回来才说。我们这次是反过来的,先写完代码才发现门槛。
客服邮箱必须同域,别妥协
这条我们来回改了三次,值得单独记:
- 先用
[email protected](Creem 邮件里举的例子)→ 与新站点域名不同域; - 用户要求改成
[email protected]→ 我明确提示了这会卡在第 4 条(gmail 与网站不同域); - 最终改回
[email protected],同域,只需给这个地址配一条邮箱转发到常用邮箱。
不要用 gmail/qq 之类的公共邮箱去满足这一条。把邮箱集中在一个常量文件里,改一处就能全站生效:
// src/lib/site.ts —— 品牌/联系信息集中管理,域名或主体变更只改这里
export const SUPPORT_EMAIL = "[email protected]"; // 必须与站点同域(Creem 要求)
export const SITE_URL = "https://www.handdraw.store";
export const POLICY_EFFECTIVE_DATE = "July 22, 2026";
政策页里的邮箱、页脚的联系方式、NEXT_PUBLIC_APP_URL 三处必须一致。
提审的操作路径
改完后到 Payout Accounts → Request re-review 提交复审。同时确认 Store settings 里的业务信息与站内文案一致(定价、额度、主体名称)。
[建议] 一致性检查清单:站内 plans 配置里的价格与额度、Creem 后台产品的定价、onboarding 表单里填的定价模式描述,三处必须对得上,否则审核和用户体验都会出问题。
Onboarding 表单里容易填错的字段
用户在填 Creem 商户表单时问过几处,记录如下:
- 产品/业务描述:要一句话说清「你是谁、做什么方向」,并附社交链接,这是给审核判断真实性用的。照官方给的 "Correct" 示例风格写,用英文更稳。
- 定价模式:写清档位(Free / Pro)+ 计费周期 + 每档额度,并与 Creem 产品定价保持一致。
- 收款银行账户:这里踩过一次。要填的是银行账户号(本项目遇到的格式要求是 ≤15 位),不是卡面上的 16 位卡号。另外如果是中国大陆的银行账户,不要错选成 "Hong Kong Branch";大陆/跨境收款 MoR 可能有特定要求(走 Payoneer/Wise 之类,或要求特定账号格式)。不确定就去问开户行「用于接收境外打款的账户号是多少、什么格式」,或直接问 Creem support 支持哪种 payout。这属于必须让用户去确认的事,AI 不要猜。
Content Moderation API(强制项)
接口与实现
POST /v1/moderation/prompt,同样用 x-api-key,请求体 { prompt, external_id },响应 { id, decision },decision ∈ allow | flag | deny。
必须在调用生成模型之前审核,并且 fail closed——只有明确的 allow 才放行:
export type ModerationDecision = "allow" | "flag" | "deny" | "error";
export type ModerationResult = { decision: ModerationDecision; id?: string };
export async function moderatePrompt(prompt: string, externalId?: string): Promise<ModerationResult> {
try {
const response = await fetch(`${apiBase()}/v1/moderation/prompt`, {
method: "POST",
headers: { "x-api-key": apiKey(), "Content-Type": "application/json" },
body: JSON.stringify({ prompt, ...(externalId ? { external_id: externalId } : {}) }),
signal: AbortSignal.timeout(5_000) // ← 必须:审核在生成主链路上,不能无限等
});
if (!response.ok) return { decision: "error" };
const data = (await response.json()) as { id?: string; decision?: ModerationDecision };
if (data.decision === "allow" || data.decision === "flag" || data.decision === "deny") {
return { decision: data.decision, id: data.id };
}
return { decision: "error" }; // 响应里没有合法 decision 也算失败
} catch {
return { decision: "error" }; // 网络/超时一律失败,交给调用方 fail closed
}
}
调用侧的三个要点:
// 1. 把用户可控的所有文本都拼进去审核,不要只审主字段
const userText = [subject, body.title, body.notes, body.panels]
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
.join("\n");
// 2. external_id 带上用户与本次生成的标识,方便日后追溯
const moderation = await moderatePrompt(userText, `user_${userId}:gen_${crypto.randomUUID()}`);
// 3. 分三类返回,语义要分清
if (moderation.decision === "flag" || moderation.decision === "deny") return fail("…未通过内容审核…", 400);
if (moderation.decision !== "allow") return fail("…审核服务暂时不可用…", 503); // error → 503 而非 400
审核要放在配额扣减之前
commit 7a5035e 特意把顺序调整成:参数校验 → 内容审核 → 配额检查 → 调用生成模型。原本配额检查在审核之前,会导致「一个违规请求被拦下,但已经算进了用户的月度用量」。审核不通过的请求不该消耗用户额度。

验证「key 有没有 moderation 权限」
接完之后必须实打实打一次审核端点确认 key 有权限,否则线上会因为审核调用一律失败而 fail-closed 拦掉所有生成:
BASE="https://api.creem.io"
[[ "$CREEM_API_KEY" == creem_test_* ]] && BASE="https://test-api.creem.io"
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/v1/moderation/prompt" \
-H "x-api-key: $CREEM_API_KEY" -H "Content-Type: application/json" \
-d '{"prompt":"a cat drinking coffee","external_id":"smoke_test"}'
# 期望 200
再次提醒前面 test/live 隔离那节的结论:test 模式返回的 allow 不能证明拦截逻辑有效,测试沙箱对露骨内容也会放行。
坑 4:API Key 权限与「两个 secret」的混淆
API Key 确实有细粒度权限(我一开始判断错了)
我先根据文档得出结论「Creem 的 API Key 没有 scope,是一把商店级全权密钥」,但用户的后台截图证明这是错的——创建 key 时可以按资源勾选读取/写入权限。别照搬旧结论,以用户后台的实际界面为准。
按最小权限原则,只勾当前代码真正调用的:
| 代码里的调用 | 需要的权限 |
|---|---|
POST /v1/checkouts | Checkouts 写入 ← 必需 |
POST /v1/moderation/prompt | Moderation(本项目实测该 key 无需额外授权即可调用,仍建议显式勾上) |
GET /v1/products/{id}(仅用于核对价格) | Products 读取(可选) |
若要加「管理/取消订阅」入口(POST /v1/customers/billing 生成客户门户链接) | Customers 写入(本项目未做) |
其余留空。生成的 key 与当前 test/live 模式绑定。
CREEM_API_KEY 与 CREEM_WEBHOOK_SECRET 是两样东西
用户在这里困惑过。明确区分:
CREEM_API_KEY→ 你的服务端主动调用 Creem(建结账、审核 prompt);CREEM_WEBHOOK_SECRET→ 验证 Creem 发给你的回调签名,不走 API Key,也不受 API Key 权限影响。
所以「给 API Key 勾了 webhook 权限」不会让验签变得可用,反之验签失败也不要去动 API Key。
切 Live 上线清单
按顺序执行,缺一步就收不到钱或掉单:
- Creem 后台关闭 Test Mode。
- 通过账户与 Payout 验证,这一步是人工审核。
- 重新创建 live 订阅产品 → 新的
CREEM_PRODUCT_ID。 - Developers → 拿 live API Key(
creem_前缀)→ 新的CREEM_API_KEY。 - 新增生产 webhook 端点
https://<正式域名>/api/webhooks/creem,勾checkout.completed+subscription.*→ 新的CREEM_WEBHOOK_SECRET。 - Vercel 环境变量(Production)更新这三个 +
NEXT_PUBLIC_APP_URL=https://<正式域名>。 - 重新部署(环境变量改动不会自动生效于已有构建)。
- 用真实卡支付一笔验证全链路:结账成功 → webhook 到达 → 该用户 plan 变
pro+ 配额提升 → 取消后回落free。可以先把定价临时设低来验。
由于域名是从 key 前缀推导的,第 3–7 步不需要改任何代码。
踩到过的一个坑:本地 .env.local 配好了不等于线上配好了。部署后一定要确认 Vercel 上三个环境(Production/Preview/Development)都有 CREEM_*——我们当时以为线上缺变量导致内容审核没生效,实际是用户早已加过。先查证再下结论,用 vercel env ls 而不是靠推测。
给后续 AI 的行为准则
- 先问合规门槛,再写代码。AI 图像/视频生成品类有「成熟产品」硬门槛,全新产品可能根本过不了审。代码全写完才发现,是流程错误。
- 不要打印或索取明文密钥。让用户自己写进
.env.local/ Vercel,只回「配好了」;用sed 's/=.*/=<hidden>/'验证键名存在即可。 - 不要越界改价格、货币、额度。这些是业务决策,且与 Creem 后台产品强绑定。改任何展示价格时,主动提示「真实扣款由 Creem 产品决定」。
- 文档结论要以用户后台实际界面为准。API Key 权限那次就是照文档下了错结论,被用户贴的截图纠正。看到截图,优先采信截图。
- 合规相关的开关不要做成可选。moderation 从强制改成 opt-in、3 天后又改回强制,来回两次都是白做。
- 区分「AI 能做的」和「只有用户能做的」。域名绑定、邮箱开通、银行账户、资质证明、点 Request re-review 都只能用户做,整改清单要按这个维度拆开列,别混在一起。
- fail closed 的代价要算清。审核服务不可用时拦掉所有生成是正确的安全姿态,但必须配超时(本项目 5s)、明确的 503 语义、以及上线前的权限 smoke test。否则一次配置失误就是全站生成不可用。