返回博客

Claude 接入 Creem 实战踩坑手册

作者 约 9 分钟读完

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


Claude 接入 Creem 实战踩坑手册

最近,为 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 APIcommit 1317c2b
反复摇摆moderation 先设为 opt-in(7794cba),3 天后改回强制(7a5035e)走了弯路,见下文 moderation 一节
价格核对页面写 $6.99,用 API 核对 Creem 上的产品价格一致(699 分),但仍是 test 模式产品

坑 1:test/live 双环境是完全隔离的

切换到正式收款,需要重建而不是切换:

项testlive
API 域名test-api.creem.ioapi.creem.io
API Keycreem_test_...creem_...
产品测试产品 id必须重新创建,得到新的 CREEM_PRODUCT_ID
Webhook测试端点 secret必须重新配置端点,得到新的 CREEM_WEBHOOK_SECRET

test 与 live 是两套完全隔离的世界

让这件事不痛的做法只有一个:不要把域名写死,也不要引入 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),四个点缺一个就验不过:

  1. 必须读原始文本 body:await request.text(),然后用这个字符串计算 HMAC。一旦先 request.json() 再 JSON.stringify 回去,字节序/空格变了,签名永远不匹配。
  2. 请求头名是 creem-signature,值是 hex 字符串——不是 base64,没有 t=,v1= 这种 Stripe 式的复合格式。
  3. timing-safe 比较前先比长度:timingSafeEqual 在长度不等时会抛异常,必须先判断再比。
  4. Next.js 路由要声明 export const runtime = "nodejs",因为用了 node:crypto。

Webhook 验签的四步链路

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@<你的域名>;用户去开通该邮箱(转发到常用邮箱即可)
5AI 图像产品必须有 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 条打回来才说。我们这次是反过来的,先写完代码才发现门槛。

客服邮箱必须同域,别妥协

这条我们来回改了三次,值得单独记:

  1. 先用 [email protected](Creem 邮件里举的例子)→ 与新站点域名不同域;
  2. 用户要求改成 [email protected] → 我明确提示了这会卡在第 4 条(gmail 与网站不同域);
  3. 最终改回 [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/checkoutsCheckouts 写入 ← 必需
POST /v1/moderation/promptModeration(本项目实测该 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 上线清单

按顺序执行,缺一步就收不到钱或掉单:

  1. Creem 后台关闭 Test Mode。
  2. 通过账户与 Payout 验证,这一步是人工审核。
  3. 重新创建 live 订阅产品 → 新的 CREEM_PRODUCT_ID。
  4. Developers → 拿 live API Key(creem_ 前缀)→ 新的 CREEM_API_KEY。
  5. 新增生产 webhook 端点 https://<正式域名>/api/webhooks/creem,勾 checkout.completed + subscription.* → 新的 CREEM_WEBHOOK_SECRET。
  6. Vercel 环境变量(Production)更新这三个 + NEXT_PUBLIC_APP_URL=https://<正式域名>。
  7. 重新部署(环境变量改动不会自动生效于已有构建)。
  8. 用真实卡支付一笔验证全链路:结账成功 → 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。否则一次配置失误就是全站生成不可用。