客户已经付款,邮箱里也拿到了 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 numberLemon Squeezy Ordersorder_created 是否存在
license key / key_shortreceipt、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_createdLicense key object创建或绑定应用内授权只订阅 order_created,漏掉 key 本身
license_key_updatedLicense key object同步 disabled、expired 等状态只在创建时写库,后续状态不更新
order_createdOrder object保存金额、税费、邮箱、receipt 链接用它和 license_key_created 各发一次权益
order_refundedOrder object标记退款,按产品策略停用或人工处理退款后仍保留永久授权
subscription_createdSubscription object建立订阅关系和周期字段以为订阅创建就等于 key 已写入
subscription_updatedSubscription object同步取消、恢复、付款方式等变化漏掉 catch-all 状态更新
subscription_expiredSubscription object停用订阅期结束后的权益在 cancelled 当天就提前停用

官方 event types 页明确写到,启用 license 的订单会出现 license_key_created,并且 order_created 会和它一起发送。订阅产品的典型初始事件还会包含 subscription_createdsubscription_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/jsonX-Event-NameX-Signature

排查时至少查这些点:

检查点正常信号异常信号处理动作
后台 event log能看到对应 event 和 payload没有任何 license 事件回到 product/variant 设置查 key 是否启用
HTTP 状态码endpoint 返回 2004xx、5xx、timeout修 endpoint 后从后台重放
X-Event-Name和 payload meta.event_name 一致header 被网关丢失在反向代理保留原始 header
X-SignatureHMAC 校验通过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_updatedsubscription_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、邮箱、金额、receiptorders / payments先补订单映射,不立刻补权益
license keykey、key_short、status、activation_limitlicenses以 license id 为主键补录
客户customer id、user_emailusers / customers邮箱不一致时人工确认归属
订阅subscription id、status、renews_at、ends_atsubscriptionscancelled 不等于 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
订阅 SaaSsubscription status + license key status补订阅、周期、license 映射subscription expired
团队版 SaaSsubscription + seat/workspace补 workspace owner 和 seat 数付款失败进入最终 expired

客服处理时可以临时给客户开通,但不要绕过数据模型。正确做法是创建一条 manual_grantslicense_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 后台为准。

相关阅读