無料版をWordPress.orgで配って、有料版を自分で売る。個人開発だと、よくある形だと思います。自分もこの形にしたのですが、いざ有料版を作る段になって、いちばん悩んだのが「買った人だけが使える」をどう実装するか、でした。
決済はStripeに任せられます。問題は、そのあとです。Stripeで払った人に、どうやって鍵を渡し、プラグイン側でどう確認し、解約した人をどう止めるか。ここは、決済とは別に、自分で組む必要があります。一通り作ってみて落ち着いた形を、書いておきます。特定の正解というより、標準的で無理のない構成として読んでください。
全体像:3つの登場人物
関わるのは、3つです。
Stripe。決済とサブスクリプションを管理します。
自前のライセンスサーバー。Stripeの決済完了を受け取って、ライセンスキーを発行し、あとで「このキーは有効か」に答えます。自分の場合、プラグインを配っているサイト側に置きました。
プラグイン本体。利用者が入力したキーを、ライセンスサーバーに問い合わせて、有効なら有料機能を解放します。
流れにすると、こうです。買う→Stripeが決済→webhookでライセンスサーバーがキー発行→メールでキーを渡す→利用者がプラグインにキーを入力→プラグインがサーバーに検証を問い合わせ→有効なら機能解放。順に見ます。
1. 決済を受けて、キーを発行する(webhook)
キーの発行は、購入画面の「成功ページ」ではなく、Stripeのwebhookで行います。成功ページはユーザーが閉じたり、リロードしたりするので、そこで発行すると取りこぼす。決済の事実は、webhookで受けるのが確実です。
// ライセンスサーバー側:Stripe webhook 受け口
// checkout.session.completed や invoice.paid を受ける
$payload = @file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
// 署名検証。これをやらないと、誰でも偽の発行リクエストを送れる
$event = \Stripe\Webhook::constructEvent(
$payload, $sig_header, MY_STRIPE_WEBHOOK_SECRET
);
} catch (\Throwable $e) {
http_response_code(400);
exit;
}
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
$email = $session->customer_details->email ?? '';
$customer = $session->customer; // Stripeの顧客ID
$sub = $session->subscription; // サブスクID(サブスクなら)
$license_key = my_issue_license($email, $customer, $sub);
my_send_license_email($email, $license_key);
}
http_response_code(200);
肝は、署名検証(constructEvent)です。これを省くと、webhookのURLを知った誰かが、偽の「決済完了」を投げて、キーを発行させられます。必ず署名を検証します。
発行したキーは、Stripeの顧客IDやサブスクIDと紐づけてDBに保存します。あとで「このキーの購読は、まだ生きているか」を確認するために、この紐づけが要ります。
2. キーの形は、推測できないものにする
ライセンスキーは、連番や、メールアドレスから作れる値にしないほうがいいです。総当たりや推測で、他人のキーを当てられてしまうので。
function my_generate_license_key(): string {
// 推測不可能な乱数から作る
return strtoupper(bin2hex(random_bytes(16))); // 32文字
}
random_bytes で作った乱数をキーにして、DBには紐づけ情報と一緒に保存します。キー自体には意味を持たせず、「DBを引くための、当てられない文字列」と割り切るのが、シンプルで安全でした。
3. プラグイン側:キーを検証する
プラグインは、利用者が入力したキーを、ライセンスサーバーのAPIに送って、有効かどうかを尋ねます。
// プラグイン側:サーバーにキーの有効性を問い合わせる
function myplugin_verify_license(string $key): array {
$res = wp_remote_post('https://example.com/wp-json/mylic/v1/verify', [
'timeout' => 8,
'body' => [
'key' => $key,
'site' => home_url(), // どのサイトで使っているか(台数管理する場合)
],
]);
if (is_wp_error($res)) {
// サーバーに繋がらないときの扱いは後述(重要)
return ['status' => 'unreachable'];
}
$body = json_decode(wp_remote_retrieve_body($res), true);
return is_array($body) ? $body : ['status' => 'invalid'];
}
サーバー側は、受け取ったキーをDBで引いて、Stripeの購読が生きているかを見て、valid / expired / invalid を返します。
// ライセンスサーバー側:検証エンドポイント(概念)
$row = my_find_license($key);
if (!$row) {
return ['status' => 'invalid'];
}
// Stripe側の購読状態も確認する(解約・支払い失敗を反映するため)
$sub = \Stripe\Subscription::retrieve($row['subscription_id']);
if (in_array($sub->status, ['active', 'trialing'], true)) {
return ['status' => 'valid', 'expires' => $sub->current_period_end];
}
return ['status' => 'expired'];
4. 検証結果は、キャッシュする(ここが実は重要)
毎回サーバーに問い合わせると、2つまずいことが起きます。ページ表示のたびに外部通信が入って重くなること。そして、ライセンスサーバーが落ちたら、正規の利用者まで有料機能が使えなくなること。
なので、検証結果は transient でキャッシュします。
function myplugin_is_pro(): bool {
$cached = get_transient('myplugin_license_status');
if ($cached !== false) {
return $cached === 'valid';
}
$key = get_option('myplugin_license_key', '');
$result = myplugin_verify_license($key);
if ($result['status'] === 'unreachable') {
// サーバーに繋がらないときは、直前の状態を猶予で維持する
$grace = get_option('myplugin_last_valid', 0);
return $grace && (time() - $grace) < DAY_IN_SECONDS * 7;
}
$valid = $result['status'] === 'valid';
if ($valid) {
update_option('myplugin_last_valid', time());
}
// 12時間キャッシュ。解約の反映が半日遅れる程度は許容する
set_transient('myplugin_license_status', $valid ? 'valid' : 'invalid', 12 * HOUR_IN_SECONDS);
return $valid;
}
猶予期間(grace)の考え方が大事でした。サーバーに繋がらないときに、いきなり有料機能を止めると、こちらの障害で相手のサイトが不便になります。かといって永遠に使えると、解約者も使い続けられる。なので、「最後に有効だった時刻から一定期間は、繋がらなくても維持する」という猶予を持たせました。障害には寛容に、でも無期限ではなく。
5. 解約・失効を、どう反映するか
解約は、2つの経路で拾えます。
ひとつは、上の検証で毎回Stripeの購読状態を見ているので、キャッシュが切れた次の検証で expired になります。最大でキャッシュ期間(この例なら12時間)遅れて反映されます。
もうひとつは、Stripeのwebhookで customer.subscription.deleted を受けて、サーバー側のキーを即座に無効化する方法です。即時に反映したいなら、こちらも実装します。
if ($event->type === 'customer.subscription.deleted') {
my_deactivate_license_by_subscription($event->data->object->id);
}
自分は、そこまで即時性は要らなかったので、キャッシュ切れでの反映に任せています。売っているものの性質(即止めが必要か)で、どちらにするか決めるといいです。
6. 有料版の配布と、自動更新
WordPress.orgには、無料版だけを置きます。有料版のコードをリポジトリに入れると、誰でもダウンロードできてしまうので。有料版(アドオンや、Pro機能を含むzip)は、自前のサーバーから配ります。
自動更新は、site_transient_update_plugins フィルタに、自前サーバーの更新情報を差し込む形で実装できます。プラグインが更新チェックのとき、ライセンスキーを添えて自前サーバーに問い合わせ、有効なキーにだけ最新版のダウンロードURLを返す。これで、有効なライセンスを持つ人だけが更新を受け取れます。
正直なところ、これは万能ではありません
限界も書いておきます。
クライアント側(プラグイン)で myplugin_is_pro() を見て機能を出し分けている以上、コードを書き換えられれば、検証を迂回されます。PHPは配布した時点で読めるので、これは原理的に防ぎきれません。
なので、自分は「完全に不正利用を防ぐ」ことは目標にしていません。目標は、普通に使う人が、普通に買って、普通に使える導線を整えることです。鍵を発行して、検証して、解約を反映する。善意の利用者にとって不便がなく、悪意なく使い続けてしまう事故(解約したのに動く、みたいな)が起きない。そこまでを、現実的なラインとしています。迂回する気がある人を完全に止めるのは、個人開発のコストに合いませんでした。
早見でまとめ
- キー発行は成功ページではなく、Stripe webhook で。署名検証(
constructEvent)は必須 - キーは
random_bytesの推測不可能な乱数。DBを引くための当てられない文字列と割り切る - プラグインは自前APIにキーを送って検証。サーバーはStripeの購読状態も見て valid/expired を返す
- 検証結果は transient でキャッシュ。毎回問い合わせない。サーバー障害時は猶予期間で維持
- 解約反映は、キャッシュ切れでの反映か、
subscription.deletedwebhook での即時無効化 - 有料版コードはWordPress.orgに置かず自前配布。自動更新は update_plugins フィルタ+キー確認
- クライアント検証は原理的に迂回可能。完全防止ではなく、善意の利用者の導線整備を目標にする
決済そのものより、その周りの「発行・検証・キャッシュ・失効」のほうが、作ることが多かったです。役に立ったらストックして、有料版を作るとき見返してください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。