# Claude 接入 Creem 实战踩坑手册

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

- 原文链接: https://laojin.blog/blog/20260813_creem_integration_playbook
- 作者: 老金
- 发布日期: 2026-08-13
- 标签: Creem, 订阅支付, Next.js, 合规审核, Claude Code

---

![Claude 接入 Creem 实战踩坑手册](https://pic-1258874139.cos.ap-hongkong.myqcloud.com/laojinblog/posts/20260813/00_cover.png)

最近，为 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` |

![test 与 live 是两套完全隔离的世界](https://pic-1258874139.cos.ap-hongkong.myqcloud.com/laojinblog/posts/20260813/01_test_live_isolation.png)

让这件事不痛的做法只有一个：不要把域名写死，也不要引入 `CREEM_ENV` 之类的第二个开关，从 API Key 前缀推导域名。切 live 时一行代码都不用改，只换环境变量 + 重新部署。

```ts
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 验签的四步链路](https://pic-1258874139.cos.ap-hongkong.myqcloud.com/laojinblog/posts/20260813/02_webhook_signature.png)

```ts
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 打本地端点即可，能覆盖「验签通过 → 升级 → 取消回落」全链路：

```bash
# 用 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` 取回：

```ts
// 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()` 归一化函数：

```ts
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 条打回来才说。我们这次是反过来的，先写完代码才发现门槛。

### 客服邮箱必须同域，别妥协

这条我们来回改了三次，值得单独记：

1. 先用 `manager@freemanapp.com`（Creem 邮件里举的例子）→ 与新站点域名不同域；
2. 用户要求改成 `freeman5860@gmail.com` → 我明确提示了这会卡在第 4 条（gmail 与网站不同域）；
3. 最终改回 `support@handdraw.store`，同域，只需给这个地址配一条邮箱转发到常用邮箱。

不要用 gmail/qq 之类的公共邮箱去满足这一条。把邮箱集中在一个常量文件里，改一处就能全站生效：

```ts
// src/lib/site.ts —— 品牌/联系信息集中管理，域名或主体变更只改这里
export const SUPPORT_EMAIL = "support@handdraw.store"; // 必须与站点同域（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` 才放行：

```ts
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
  }
}
```

调用侧的三个要点：

```ts
// 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` 特意把顺序调整成：**参数校验 → 内容审核 → 配额检查 → 调用生成模型**。原本配额检查在审核之前，会导致「一个违规请求被拦下，但已经算进了用户的月度用量」。审核不通过的请求不该消耗用户额度。

![审核放在配额扣减之前的请求管线](https://pic-1258874139.cos.ap-hongkong.myqcloud.com/laojinblog/posts/20260813/03_moderation_pipeline.png)

### 验证「key 有没有 moderation 权限」

接完之后必须实打实打一次审核端点确认 key 有权限，否则线上会因为审核调用一律失败而 fail-closed 拦掉所有生成：

```bash
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 上线清单

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

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。否则一次配置失误就是全站生成不可用。
