今では、多くの個人開発の SaaS が Merchant of Record(MoR)の上で動いています。Paddle や Lemon Squeezy は、売上税、VAT、チャージバックを引き受けてくれます。EU の VAT のしきい値について考えたくない、ひとりで事業をしている創業者にとっては、本当によい取引です。
ただし、Webhook の形は Stripe とは違い、その違いは負荷がかかったときにしか表に出ない種類のものです。Stripe 用のハンドラーを移植して署名検証だけ書き換えたなら、知っておくべき数字が 2 つあります。
TL;DR
- Paddle は 5 秒以内に
200を返すことを求めます。Stripe は数字をまったく公表していません - Paddle のライブモードは約 2 日間で最大 60 回試行し、最初の 1 時間に約 10 回、最初の 1 日に約 45 回試行します
- Paddle のサンドボックスは、まったく違う動きをします。約 2 分以内に 3 回試行するだけです
- Lemon Squeezy は
X-Signatureで署名します。これは生のリクエストボディに対する HMAC-SHA256 の hex ダイジェストです。ダッシュボードからの手動の再送にも対応しています - Paddle のリトライは 1 分間隔で始まり、間隔がゆっくりとしか広がらないので、遅いハンドラーは Stripe よりはるかに厳しい目に遭います
5 秒と明記されている
Stripe は数字を一切公表しておらず、コミュニティでは約 20 秒を目安にしています。Paddle は数字を公表しています。エンドポイントは「リクエストを受信してから 5 秒以内に 200 を返す」必要があります。
5 秒という値で、Paddle は Stripe ではなく Shopify と同じ部類に入ります。Stripe 用のハンドラーが前提にしていた持ち時間の、およそ 4 分の 1 です。ライセンスキーを発行し、データベースに書き込み、サードパーティの API でウェルカムメールを送るフルフィルメントのハンドラーは、20 秒には余裕で収まりますが、5 秒には到底収まりません。
生き残る形は、どこでも生き残る形と同じです。検証し、永続化し、応答し、処理は後で行います。
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes, before parsing
if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
return new Response("unauthorized", { status: 401 });
}
await queue.publish(raw); // durable, ~10ms
return new Response(null, { status: 200 }); // inside 5s
}
本当の違いはリトライの曲線
Paddle のライブモードのスケジュールは、「約 2 日間で合計最大 60 回試行します。最初の 1 時間に約 10 回、最初の 1 日で約 45 回の試行が行われます」というものです。
Stripe は、数回の試行を指数バックオフで最長 3 日間に分散させます。Paddle の待ち時間は 1 分から始まってゆっくりとしか伸びないので、60 回の試行のほとんどが序盤に集中します。
壊れてはいないが遅いだけのハンドラーにとって、これが何を意味するかを考えてみてください。データベースが一時的に重いせいで、処理に 6 秒かかるとします。Paddle は 5 秒でタイムアウトし、リトライします。最初の 1 時間でそれが約 10 回起き、そのたびにハンドラーの 6 秒の処理が新たに始まります。しかも相手は、そもそも問題の原因になっているデータベースです。
Webhook プロバイダー別リファレンスでほかの送信元と並べると、Paddle は外れ値です。指数バックオフは、苦しんでいるシステムに回復の余地を与えるためにあります。Paddle も自社のスケジュールを指数バックオフと呼んでいますが、ここまで序盤に集中する曲線は、エンドポイントが最も耐えられないときに、最も強い圧力をかけます。課金イベントでは配信を粘り強く続けてほしいので、Paddle が不合理なわけではありません。ただし、遅いハンドラーのコストは、同じハンドラーを Stripe で動かす場合よりはるかに速く膨らみます。
また、冪等でないハンドラーは、同じ処理を 1 日に数回ではなく、1 時間に約 10 回も実行しかねません。その処理が「ライセンスを発行する」や「領収書を送る」なら、顧客は気づきます。
サンドボックスではこれが見えない
「サンドボックスのアカウントでは、合計 3 回試行し、すべて約 2 分以内に行います。」
約 2 分間に 3 回の試行と、約 2 日間に 60 回の試行は、別のシステムです。サンドボックスで確かめたタイミングの前提は、本番環境ではすべて間違っています。しかも安全側に間違っていて、これが最悪の間違い方です。ハンドラーは、本物のお金を扱い始めるその瞬間まで、問題なさそうに見えます。
本当に重要なリトライの挙動を試したいなら、ライブモードでテスト用の通知先に対してわざと 500 を返し、最初の 1 時間を観察してください。
署名検証
Paddle は、タイムスタンプと H1 署名を含む Paddle-Signature ヘッダーで署名します。大まかには、Stripe の t=/v1= 方式と同じ系統です。Lemon Squeezy は X-Signature で署名します。これは、Webhook の署名シークレットを使って生のリクエストボディから計算した HMAC-SHA256 の hex ダイジェストです。
どちらにも、すべてのプロバイダーに共通する落とし穴があります。HMAC は、届いたバイト列そのものに対して計算されます。先に JSON をパースして再シリアライズすると、ログのペイロードは完全に正しく見えるのに、ダイジェストは変わってしまいます。これはあらゆるプロバイダーを通じて最も多い Webhook のバグで、プロバイダーごとの仕組みは Stripe、GitHub、Shopify の Webhook 署名を検証するにあります。
import crypto from "node:crypto";
function verifyLemonSqueezy(raw: string, header: string | null, secret: string) {
if (!header) return false;
const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
timingSafeEqual の前の長さチェックは重要です。timingSafeEqual は長さが違うと false を返すのではなく例外を投げますが、攻撃者が送った不正な形式のヘッダーで 500 が返ってはいけないからです。
Next.js の App Router では、何よりも先に await req.text() を呼びます。サーバーレス全般については、Vercel で Webhook を取りこぼさずに受信するで、ボディのストリームを黙って消費してしまうボディパーサーを扱っています。
冪等性と、MoR ならではの落とし穴
Paddle はキーにできるイベント ID を送ってくるので、パターンはいつもの一意制約です。
INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;
影響を受けた行が 0 なら、すでに受け取ったイベントです。エラーではなく 200 を返してください。さもないと、60 回の試行を含むループを作ったことになります。
MoR ならではの落とし穴があります。同じ顧客のサブスクリプションのライフサイクルイベントは立て続けに届くことがあり、リトライで順序が入れ替わることがあります。1 時間後にリトライされた subscription.updated が subscription.canceled の後に届き、サブスクリプションを有効な状態に戻してしまうことがあります。どちらのイベントも別物で、どちらも正当なので、重複排除は役に立ちません。必要なのは、イベント自身のタイムスタンプをキーにしたバージョンガードです。これは 同じ Webhook を 2 回受け取ったときで説明しているのと同じパターンです。
Paddle のリトライの密度を考えると、古いイベントが新しいイベントの後に届く機会は、Stripe よりはるかに多くなります。ここでは、これは机上の心配ではありません。
Stripe から移るときに変えること
ハンドラーの持ち時間を 4 分の 1 にします。検証、永続化、応答に含まれないものは、すべてワーカーに移します。
重複はまれではなく、よくあるものだと考えます。1 時間に約 10 回の試行があると、冪等性にどれだけ気を配るべきかの計算が変わります。
ライフサイクルのあるものには、すべてバージョンガードを付けます。特にサブスクリプションです。
サンドボックスに合わせて調整しないでください。そのリトライの挙動は、本番環境とはまったく違います。
生のペイロードのログを残します。Lemon Squeezy にはダッシュボードからの手動の再送があり、Paddle は配信履歴を保持しています。しかし、どちらもプロバイダーの保存期間に従うプロバイダー側のコピーで、「自分たちのハンドラーは実際にそれをどう処理したのか」には答えてくれません。
AnyHook の位置づけ
5 秒は伝送の制約で、60 回の試行は伝送の挙動です。接続を別のものが受け持つようになれば、どちらも自分のコードには関係なくなります。
通知先を in.anyhook.net/you/paddle に向けます。AnyHook はエッジで検証と永続化を行い、約 1 秒で 200 を返します。そのため 5 秒の応答期限は、2 つのことしかしないワーカーが満たします。リトライするものがないので、60 回の試行の曲線が動き出すこともありません。
- ハンドラーにはプランに応じて、そのコンピュート枠の範囲内で 60〜300 秒が与えられ、データベースが遅くても、数分ごとのリトライを招くことはなくなります
- すべてのイベントは配信前に保存され、保存時に暗号化されます。そのため、失敗したデプロイの間の分も、範囲を選んでクリックするだけで再送できます
- エンドポイントへの配信は、指数バックオフでリトライされます。回復しつつあるシステムには、これが正しい曲線です
- 連続失敗が 1 回、5 回、20 回に達するとアラートが届き、20 回で配信は自動的に一時停止します。壊れたハンドラーが叩かれ続けることはありません
リトライも再送もあるので、ハンドラーには引き続き冪等性とバージョンガードが必要です。
まとめ
MoR の決済は、個人開発の SaaS にとってよい取引です。ただし Webhook の約束事は、おそらく移行元のものより厳しくなります。5 秒、約 2 日間で最大 60 回の試行、しかもそのほとんどが序盤です。生のバイト列に対して検証し、処理の前に応答し、プロバイダーのイベント ID で重複排除し、サブスクリプションへの書き込みはイベント自身のタイムスタンプでガードします。
Stripe で 2 年間問題なく動いてきたハンドラーが、ここでも自動的に問題なく動くわけではありません。そして、それを教えてくれるのはサンドボックスではありません。