客户已经付款,邮箱里也拿到了 Lemon Squeezy receipt,但你的 SaaS 后台仍显示「未购买」。这类事故不要先改套餐、补优惠码或重新创建订单。先把它当成一条异步消息丢失来处理:Lemon Squeezy 是否生成了 license key、webhook 是否发出、你的 endpoint 是否返回 200、业务表是否真正写入。
本文只讨论已经启用 Lemon Squeezy license key 的产品。根据官方文档,license key 可以在 product 或 variant 层开启;客户购买后会生成 key,并出现在收据邮件和 My Orders 页面里。订阅产品的 key 又会跟着 subscription lifecycle 走,所以一次性产品和订阅产品的补救方式不能混在一起。
第一现场:客户有 receipt,但应用没有权益
先让客服或你自己拿到 4 个字段,不要让客户重新购买:
| 字段 | 从哪里拿 | 用来判断什么 |
|---|---|---|
| 客户邮箱 | receipt、My Orders、后台 Orders | 是否匹配你应用里的 user id |
| order id / order number | Lemon Squeezy Orders | order_created 是否存在 |
| license key / key_short | receipt、License Keys 后台 | license_key_created 是否存在 |
| 购买时间和时区 | receipt、后台 event log | 和应用日志对齐 |
如果客户只有 receipt,没有 license key,先查产品或 variant 是否真的开启了 Generate License Keys。官方 license key 教程写明,只有开启后,每次购买才会生成 key 并通过 receipt 发给客户。
如果客户有 license key,但你的应用没权限,问题大概率不在支付,而在 webhook 接收、解析、队列或写库。
event 类型的订阅策略
Lemon Squeezy 的 webhook event 类型里,和 license key 授权最相关的是这几组:
| event | 官方发送对象 | 你应该做什么 | 常见误判 |
|---|---|---|---|
license_key_created | License key object | 创建或绑定应用内授权 | 只订阅 order_created,漏掉 key 本身 |
license_key_updated | License key object | 同步 disabled、expired 等状态 | 只在创建时写库,后续状态不更新 |
order_created | Order object | 保存金额、税费、邮箱、receipt 链接 | 用它和 license_key_created 各发一次权益 |
order_refunded | Order object | 标记退款,按产品策略停用或人工处理 | 退款后仍保留永久授权 |
subscription_created | Subscription object | 建立订阅关系和周期字段 | 以为订阅创建就等于 key 已写入 |
subscription_updated | Subscription object | 同步取消、恢复、付款方式等变化 | 漏掉 catch-all 状态更新 |
subscription_expired | Subscription object | 停用订阅期结束后的权益 | 在 cancelled 当天就提前停用 |
官方 event types 页明确写到,启用 license 的订单会出现 license_key_created,并且 order_created 会和它一起发送。订阅产品的典型初始事件还会包含 subscription_created 和 subscription_payment_success。
所以 handler 的职责要拆清楚:license_key_created 发放 license 权益,order_created 补订单财务信息,subscription_updated 维护订阅状态。不要让每个事件都各自「发现客户付费了,于是开通一次」。
webhook 到达判断:看 headers,不只看日志行
Lemon Squeezy webhook 请求是 POST,body 是 JSON:API resource object。官方 webhook requests 文档列出的关键 headers 包括 Content-Type: application/json、X-Event-Name 和 X-Signature。
排查时至少查这些点:
| 检查点 | 正常信号 | 异常信号 | 处理动作 |
|---|---|---|---|
| 后台 event log | 能看到对应 event 和 payload | 没有任何 license 事件 | 回到 product/variant 设置查 key 是否启用 |
| HTTP 状态码 | endpoint 返回 200 | 4xx、5xx、timeout | 修 endpoint 后从后台重放 |
X-Event-Name | 和 payload meta.event_name 一致 | header 被网关丢失 | 在反向代理保留原始 header |
X-Signature | HMAC 校验通过 | raw body 被解析后再验签 | 用原始 body 计算摘要 |
| 环境字段 | test/live 各进各的表 | test 数据写进生产用户 | 给 webhook endpoint 和 API key 分环境 |
| 业务写库 | license id 和 user id 入库 | 只打印「received」 | 把处理状态落表 |
官方签名文档说明,Lemon Squeezy 会用 signing secret 生成 HMAC hex digest,并放在 X-Signature 里。Node、Rails、Django 等框架常见坑是中间件先解析 body,验签时已经拿不到原始 payload。签名失败不能直接吞掉,至少要记录 event name、时间、请求 IP、body 长度和失败原因。
重放之前:先补幂等表
Lemon Squeezy 帮助页说明,最近发送的 webhooks 可以在设置页查看 payload,也可以按需 resend。Webhook requests 文档还说明,如果你的 endpoint 没返回 200,Lemon Squeezy 会最多再试 3 次,间隔类似 5 秒、25 秒、125 秒;之后这次请求会被认为失败,不再自动继续。
这意味着重放不是「再点一次就好」。你要先保证同一个事件重复进来不会重复发放权益。
一个够用的幂等表可以这样设计:
create table lemonsqueezy_webhook_events (
id bigserial primary key,
event_name text not null,
resource_type text not null,
resource_id text not null,
mode text not null check (mode in ('test', 'live')),
payload_sha256 text not null,
processing_status text not null,
processed_at timestamptz,
error_message text,
created_at timestamptz not null default now(),
unique (event_name, resource_type, resource_id, mode, payload_sha256)
);
处理逻辑不要只靠「有没有收到过」。更稳的是先插入事件记录,插入成功才进入业务事务;如果唯一键冲突,就读取旧处理状态。license_key_created 这类创建事件可以用资源键挡住重复发放;license_key_updated、subscription_updated 这类更新事件要保留 payload hash 或时间戳,允许后续状态变化进入同一个资源的最新状态同步。
async function handleLemonSqueezyWebhook(payload: any) {
const eventName = payload.meta?.event_name;
const resourceType = payload.data?.type;
const resourceId = String(payload.data?.id);
const mode = payload.data?.attributes?.test_mode ? "test" : "live";
const inserted = await db.webhookEvents.insertIfNotExists({
eventName,
resourceType,
resourceId,
mode,
payloadSha256: sha256(JSON.stringify(payload)),
processingStatus: "processing"
});
if (!inserted) {
return { ok: true, skipped: "duplicate_event" };
}
await db.transaction(async (tx) => {
if (eventName === "license_key_created") {
await grantLicenseAccess(tx, payload.data);
}
if (eventName === "license_key_updated") {
await upsertLatestLicenseStatus(tx, payload.data);
}
await tx.webhookEvents.markProcessed(eventName, resourceType, resourceId, mode, sha256(JSON.stringify(payload)));
});
return { ok: true };
}
这里的唯一键加上 payload_sha256,是因为 Lemon Squeezy payload 里没有一个你一定会长期依赖的通用 event id。对创建类事件,业务表还要用 license_key_id 或订单映射挡住重复发放;对更新类事件,允许新 payload 进入,再用 upsert 把 license、subscription 或 customer 同步到最新状态。
后台对账:别只盯应用用户表
漏收 webhook 后,应用用户表经常是空的;只看自己数据库会误以为客户没付费。对账要从 Lemon Squeezy 后台往回推。
| 对账对象 | Lemon Squeezy 侧 | 应用侧 | 不一致时怎么补 |
|---|---|---|---|
| 订单 | order id、邮箱、金额、receipt | orders / payments | 先补订单映射,不立刻补权益 |
| license key | key、key_short、status、activation_limit | licenses | 以 license id 为主键补录 |
| 客户 | customer id、user_email | users / customers | 邮箱不一致时人工确认归属 |
| 订阅 | subscription id、status、renews_at、ends_at | subscriptions | cancelled 不等于 expired |
| 激活实例 | license key instances | 设备或 workspace 绑定表 | 超过 activation limit 时走支持流程 |
Lemon Squeezy 的 License Key Object 包含 customer、order、order item、product、key、key_short、activation_limit 等字段;License API 激活返回的 meta 也会带 order、product、variant、customer 相关信息。你自己的表至少要存 Lemon Squeezy license key id,不要只存完整 key 字符串。
订阅产品还要额外注意状态语义。官方订阅 license 文档说明,订阅产品的 license key 会在订阅 active 时保持有效,订阅 expired 后相关 key 才会 expired。客户点击取消后通常还有 grace period,不能把 subscription_cancelled 当成「立即停用」。
一次性 license 和订阅 license 的补救方式不同
一次性产品的核心是「这笔订单是否生成了有效 key」。订阅产品的核心是「当前订阅状态是否允许继续使用这个 key」。同一个补救脚本不要同时处理两种模型。
| 产品类型 | 授权依据 | 补救动作 | 停止条件 |
|---|---|---|---|
| 一次性插件 | license_key_created + key status | 补录 license,给客户绑定下载或激活权限 | 订单 refund 或 key disabled |
| 年付模板 | order + license length | 补录到期日和 receipt 链接 | license expired |
| 订阅 SaaS | subscription status + license key status | 补订阅、周期、license 映射 | subscription expired |
| 团队版 SaaS | subscription + seat/workspace | 补 workspace owner 和 seat 数 | 付款失败进入最终 expired |
客服处理时可以临时给客户开通,但不要绕过数据模型。正确做法是创建一条 manual_grants 或 license_adjustments 记录,写清楚谁在什么时间根据哪笔 Lemon Squeezy 订单补了什么权益。后续 webhook 重放成功时,应用看到已有人工补录,就只补齐外部 id 和状态,不再重复发送「开通成功」邮件。
本文不替你下结论的情况
这篇只覆盖 Lemon Squeezy 官方 license key 和 webhook 链路,不覆盖自建支付页、第三方授权服务器、离线 license 签名算法,也不替代税务、退款和消费者保护判断。
还有几个未实测对象要单独留意:
- 不同框架对 raw body 的保留方式不同,Next.js、Express、Rails、Django、Laravel 要按各自中间件处理签名验证。
- Lemon Squeezy 后台 UI 可能调整,重放入口以 Settings -> Webhooks 里的当前页面为准。
- 订阅 dunning、付款失败重试和 expired 的具体时间线,取决于你在 Lemon Squeezy 后台的订阅设置。
- 如果你把一个 Lemon Squeezy 客户邮箱映射到多个 workspace,自动补权前要人工确认归属。
- 如果产品已经迁移到 Polar、Paddle 或 Stripe Billing,历史 key 的状态要以原 MoR 后台为准。