入门指南
License key 的发放、激活与吊销:一份能照抄的事件清单
对照 Lemon Squeezy、Keygen 与 Stripe 的公开文档,把发码、激活、校验、吊销、续期拆成五件事,写清设备上限、退款吊销、webhook 失败补偿与离线校验的真实边界。
发码是销售动作,激活是运行时动作,中间那条网络会丢事件。

先看答案
- 适合谁
- 准备给桌面软件或插件做 license key 的一人公司
- 已经能发码、但退款与换机仍靠手工处理的小团队
- 要接支付平台 webhook、却不确定失败怎么补的开发者
- 你会获得
- 先把「事件丢了怎么办」写进设计,再决定要不要做自动发码。
- 发布时间
- 实质更新
- 发布
- AOPC 编辑部
60 秒结论
一分钟看懂这篇文章
发码是销售动作,激活是运行时动作,中间那条网络会丢事件。
人负责
- 定义设备上限
- 裁决退款吊销
- 设计离线宽限
AI 负责
- 写补偿对账任务
- 校验签名与幂等
- 生成状态报表
- 列状态机
- 定事件表
- 写补偿任务
- 再开卖
直接答案
自建 license key 最容易犯的错,是把「发码」当成售后的全部:付款成功就发码,发完就以为结束了。可真正会出问题的是发码之后的四件事——激活、校验、吊销、续期,而它们全部发生在你看不见的用户机器上。
所以在写第一行发码代码之前,先把三个问题写进设计文档:
- 谁是状态的唯一真相:你的数据库、支付平台的 license 对象,还是客户端缓存?
- 事件丢了怎么办:没到、重复、乱序,各自的处理路径是什么?
- 离线能承诺什么:一台没有网络的机器,最晚多久必须知道「这个码已被吊销」?
这三个答案,决定你做的是一段发码脚本,还是一套能交给自动化跑的运营设施。
一、五个动作,五种失败方式
把生命周期拆开看,每个动作的失败方式都不一样,混在一起写就会互相掩盖:
| 动作 | 触发点 | 失败时用户看到 | 必须落库的东西 |
|---|---|---|---|
| 发放 | 付款成功 | 付了钱收不到码 | 订单号、码、绑定产品与席位 |
| 激活 | 首次启动、换机 | 「已达设备上限」 | 设备标识、激活时间、最后心跳 |
| 校验 | 每次启动、定时 | 突然变成未授权 | 校验时间、结果、宽限期起点 |
| 吊销 | 退款、拒付、滥用 | 仍在正常使用 | 吊销原因、生效时间、来源事件 ID |
| 续期 | 订阅扣款成功 | 到期当天被锁 | 新旧到期时间、上一期区间 |
这张表里最该被注意的是最后一列:凡是没落库的,事后都无法对账。 尤其是「来源事件 ID」——它是你唯一能把一条吊销记录追回到某一封支付事件上的凭据。
二、状态机:四种状态够用,但别自己发明第五种
Lemon Squeezy 的 License API 把 license key 的状态定成四个值:
A license key status can be one of:
inactive- The license key is valid but has no activations.active- The license key has one or more activations.expired- The license key’s expiry date has passed, either because the related product had a defined license length or because the license’s subscription has expired.disabled- The license key has been manually disabled from the Lemon Squeezy dashboard.
这四个值里有三个细节容易被读错:
inactive不是欠费,是「码有效,但一次都没激活过」。用它判断「用户还没开始用」,不要用它判断「没付钱」。expired有两个来源:产品设了固定授权期限,或者订阅到期。两者的补救动作完全不同——前者要引导续费,后者可能只是扣款失败。disabled是人在后台手动关的。所以它天然带一个审计问题:谁关的、为什么关、什么时候能开。
这里有一个必须自己定义的空白:这四种状态里没有「已退款」。 该页也没有说明退款后 license 会变成哪一个值。所以你只有两种自洽做法——收到退款事件就 disable,或者不动状态、让订阅到期自然变 expired。两种都能用,但必须写进条款,否则每来一次退款,客服都要现场重新判断一遍。
同一页还写了两条工程约束,值得直接抄进你的客户端设计:
All API requests must be made over HTTPS to https://api.lemonsqueezy.com.
API calls are rate limited to 60 requests per minute.
第二条对一人公司特别重要:60 次/分钟是整条链路共享的。如果每台客户端每次启动都校验、再叠加你的对账任务,用户量一上来,先撞墙的是你自己的后台任务。校验要做本地缓存,对账要错峰。
三、设备上限:同时激活,还是历史设备
Lemon Squeezy 对 active 的定义是「一次或多次激活」,但这一页没有给出上限数字与释放规则。Keygen 则把机器当成独立资源,目录里直接列着 Activate a machine、Deactivate a machine、Ping machine heartbeat、Reset heartbeat。
两家放在一起看,说明「上限」其实是三个不同的数,必须分开写:
- 同时激活数:决定「能不能在第二台电脑上用」;
- 历史设备数:决定「换过几次电脑之后还能不能激活」;
- 重置窗口:决定「多久可以自助换机一次,还是要找你人工处理」。
只写一个数字,必然出现一种投诉:条款写「3 台」,用户理解成「这辈子 3 台」,于是在第三次换机时给你写一封长信。把三个数分开写,是唯一能减少这类工单的写法。
还要提前定义「一台设备」是什么。用硬件指纹、安装 ID、还是账号登录数?三者对用户的影响完全不同:硬件指纹换硬盘就变,安装 ID 重装系统就变,账号登录数则挡不住多人共用。这个决定没有技术上的正确答案,只有条款上的诚实答案。
四、退款与拒付:别等下次启动才发现
Lemon Squeezy 的 webhook 对象示例里,订阅的事件是这两个:
“events” : [ “order_created” , “order_refunded” ]
这提醒一件事:付款事件和授权状态是两套数据。 order_created 只证明有人付了钱,它不证明码已经发出、不证明客户端已经激活、也不证明这台机器在允许的设备数内。
对一人公司最省事的做法,是把「收到退款或拒付 → 吊销」做成一条可重放的规则,而不是一次性的客服操作:
- 规则本身写在一处(什么事件、对应哪个产品、吊销还是降级、是否立即生效);
- 每次执行都记录来源事件 ID 和当时读到的远端状态;
- 规则可以拿历史事件重跑,结果一致。
这样做的价值不在自动化,而在可解释:半年后用户来问「为什么我的码被关了」,你能拿出事件 ID、时间和当时的远端状态,而不是一句「系统关的」。
五、webhook 不是送达保证
这是最容易被写错的一段。三家的措辞其实在说同一件事,只是严厉程度不同。
Keygen 写得最直白:
Webhook endpoints MUST respond with a 2xx status code. Webhooks DO NOT follow redirects, i.e. 3xx status codes. Redirects are considered a failed request. Failed requests will automatically be retried with an exponential backoff. We will perform 15 retries over approximately 3 days.
同一页紧接着写:
We do our best to always send them in a timely manner, but we DO NOT recommend relying on webhooks for events that are time-sensitive e.g., waiting on a webhook to finish processing a web request.
它还给了另一条观察延迟的口径:「Events usually get sent quickly, but sometimes there can be a small delay. If you experience a delay longer than 1 hour for the initial event, please contact us.」——注意,这是「联系他们」的阈值,不是你系统可以接受的延迟。
Stripe 的口径是:
Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode.
沙箱环境更短:只重试三次、跨度几小时。而且「手动重发一个失败事件并不会取消自动重试,即使它返回了 2xx」。
比「重试几次」更致命的是另外三件事:
1. 不保证顺序。 Stripe 原文:「Stripe doesn’t guarantee the delivery of events in the order that they’re generated.」并明确说不要用 created 判断顺序或是否已处理,要「Track event IDs to identify duplicate deliveries instead.」
2. 会重复。 「Webhook endpoints might occasionally receive the same event more than once.」防御方式是把处理过的事件 ID 记下来,再遇到就跳过。
3. 手动重试会造出一条新事件。 Keygen 的 Retry event 动作写着:它「creates a new event resource with an identical idempotency token」,并警告「If you’re not watching idempotency tokens, manually retrying an event may result in stale or duplicate data being sent to your endpoints.」
Lemon Squeezy 这两页给的东西不一样:API 页给了 last_sent_at(「Will be null if no events have been sent yet」),帮助页给了签名密钥(6–40 字符,要求「validate against this secret on each incoming webhook」)和后台的「resend webhooks」入口。但重试次数与签名算法这两页都没有写。这不是文档的缺陷,而是提醒:页面没写的部分,就是你要在自己系统里补的部分。
所以结论只有一句:webhook 当加速器,真相靠主动查询。
六、补偿任务:记住三段时间
补偿任务不需要复杂,但必须存在。它每天(或每小时)做三件事:
- 取水位:上次成功同步的时间点。注意别用
last_sent_at——它是「最后一次发送」,不是「最后一次被你成功处理」。水位只能存在你自己的库里。 - 拉增量:按水位拉取之后的订单与许可证,而不是「拉全部再比对」。前者能跑很多年,后者会在第三年超时。
- 对账并记账:本地状态与远端状态不一致时以远端为准,但要记录差异类型。
对账要处理的差异只有三类,处理方式完全不同:
| 差异 | 含义 | 处理 |
|---|---|---|
| 远端已吊销,本地仍 active | 最危险,用户在用已退款的码 | 立即同步,并保留事件 ID |
| 远端 active,本地 disabled | 大概率是你误伤 | 恢复并主动道歉 |
| 到期时间不同 | 续期事件漏了 | 以远端为准,标记「漏事件」计数 |
第三列的「漏事件」计数,比任何日志都值钱:它连续几天大于零,说明你的 webhook 端点或签名校验有系统性问题,而不是偶发丢包。
七、离线校验:能做什么,不能做什么
Keygen 关于离线的措辞,比大多数营销页诚实:
There’s no concept of a “correct” time for an offline device, since it can’t sync to an NTP server to get the real world time.
We don’t offer anti-clock-manipulation tools for offline use — that would need to be handled outside of Keygen in your own application code.
No offline-only solution will be 100% effective.
它给出的替代品是 license file:一份签名(可选加密)的许可快照,用于「caching purposes, or to allow time-limited offline use」。
所以离线方案能诚实承诺的只有两件事:离线期间不被篡改(靠签名),和离线有上限(靠宽限期)。不能承诺的是「离线也能即时吊销」——吊销要传播到那台机器,唯一的时机是它下次联网。
配套有两个细节值得一起写进设计:
- 回放:Keygen 建议「If the signature is valid, but the response date is older than 5 minutes, we recommend rejecting the response (granted the local time is synchronized using NTP).」你的缓存也要带时间戳,并且接受「本机时间不可信」这个前提。
- 宽限期写进条款:比如「连续 14 天无法联网校验则进入只读模式」,而不是含糊的「我们保留远程关闭的权利」。前者用户能预期,后者只会在出事后变成差评。
八、可以照抄的事件表
下表以 Lemon Squeezy 与 Keygen 的公开事件名为例(各家命名不同,接哪家就用哪家的名字)。第三列是我建议的处理方式,不是厂商的规定。
| 事件 | 该改什么 | 建议处理 |
|---|---|---|
order_created |
发放 | 发码;发不出去要进重试队列,不能只记日志 |
order_refunded |
吊销或降级 | 按你的规则立即生效,并记录事件 ID |
license.expired |
到期 | 只读或锁定;同时触发续费引导 |
license.revoked |
吊销 | 立即失效;这是最强的一种终止 |
license.suspended |
暂停 | 可恢复,别和吊销混用同一段代码 |
license.reinstated |
恢复 | 清掉暂停标记,保留历史 |
license.expiring-soon |
提醒 | Keygen 写「within the next 3 days」,且「Up to 1 event will be sent every day until expiration」——适合做续费提醒,不适合做扣款依据 |
machine.heartbeat.dead |
设备失联 | 只做运营观察,不要自动吊销;离线用户会误伤 |
最后一行值得单独说:心跳失联不等于盗用。 用户可能在飞机上、可能在隔离病房、可能只是三个月没打开软件。把它接进自动吊销,你会用一条自动化规则制造一批愤怒的用户。
九、上线前的八项检查
- 状态只有一套真相:客户端缓存、你的库、支付平台,三者的优先级写清楚了吗?
- 四种状态之外的空白定义了吗:退款后是 disable 还是等 expired?
- 三个设备数字分开写了吗:同时激活、历史设备、重置窗口?
- 幂等键存了吗:支付事件 ID 或幂等 token,重复投递时能直接跳过?
- 水位存在自己库里吗:不是
last_sent_at,是「最后一次成功处理」? - 对账任务能跑一年吗:增量拉取,而不是每次全量?
- 离线条款写了吗:宽限期几天、到期后是只读还是锁定?
- 误伤路径演练过吗:远端 active 本地 disabled 时,谁恢复、多久恢复?
八项里最容易被跳过的是最后一项。它不需要写代码,只需要一次桌面演练:把一条正常 license 手动 disable,看系统多久能自己发现并恢复。如果答案是「不会自动恢复」,那你的对账任务其实只做了一半。
三种最常见的误读
- 「付款成功就该能激活。」 付款只产生一条事件;从事件到用户机器上的授权,中间还有发码、投递、激活三步,每一步都会失败。把这三步的失败当成正常情况设计,而不是当成 bug。
- 「webhook 会自动重试,所以不会丢。」 重试有上限(Keygen 15 次约 3 天,Stripe 最长 3 天),而且不保证顺序、可能重复、手动重试还会造新事件。重试是缓冲,不是保证。
- 「离线也能立刻吊销。」 这是物理上做不到的事。Keygen 自己写「No offline-only solution will be 100% effective」,并把时钟篡改的防御指向「客户端自己的代码」。诚实的做法是把吊销延迟写进条款,而不是承诺一个做不到的即时性。
来源
本文所有引用均来自下列公开页面,访问日期均为 2026-10-24,发布前逐条重新验证为 HTTP 200:
- License API – Lemon Squeezy核查于 2026/10/24:activate/validate/deactivate 三个动作、四种状态定义、HTTPS 与
application/x-www-form-urlencoded要求、60 requests per minute 限流、4XX 错误码。 - The Webhook Object – Lemon Squeezy核查于 2026/10/24:webhook 归属于 store、
events为数组、last_sent_at在没有事件时为 null、示例订阅order_created与order_refunded。 - Webhooks – Lemon Squeezy Help核查于 2026/10/24:三个要素(回调 URL、签名密钥、事件列表)、签名密钥 6–40 字符且要求逐请求校验、后台可查看已发送事件并重发。
- Keygen API Reference核查于 2026/10/24:license 的 validate/suspend/reinstate/renew/revoke/check-out/check-in 动作,machines 的 activate/deactivate/heartbeat/reset heartbeat,以及离线许可文件与签名章节的入口。
- Webhooks – Keygen核查于 2026/10/24:必须回 2xx、不跟随 3xx、失败按指数退避重试「15 retries over approximately 3 days」、不建议把时效性动作押在 webhook 上、超过 1 小时延迟才联系官方、事件类型表(含
license.expiring-soon每天最多一条直到到期)、手动重试会生成相同幂等 token 的新事件。 - Signatures – Keygen核查于 2026/10/24:响应签名用于防中间人、防伪造、防回放,签名有效但响应时间超过 5 分钟建议拒绝;license key 签名可用于离线环境验证;webhook 请求签名在「webhooks automate things like billing」时尤其关键。
- Security – Keygen核查于 2026/10/24:离线设备没有「正确时间」的概念、官方不提供离线防时钟篡改工具、
No offline-only solution will be 100% effective、license file 用于缓存与限时离线使用。 - Webhooks – Stripe核查于 2026/10/24:最多 16 个 event destination、必须公网 HTTPS、签名校验必须用原始请求体、live 模式最长重试 3 天、沙箱重试三次、手动重发不取消自动重试、不保证顺序、按事件 ID 去重。