1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WordPressプラグインの有料版を、Stripeとライセンスキーで実装する。検証・キャッシュ・失効まで

1
Posted at

無料版を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.deleted webhook での即時無効化
  • 有料版コードはWordPress.orgに置かず自前配布。自動更新は update_plugins フィルタ+キー確認
  • クライアント検証は原理的に迂回可能。完全防止ではなく、善意の利用者の導線整備を目標にする

決済そのものより、その周りの「発行・検証・キャッシュ・失効」のほうが、作ることが多かったです。役に立ったらストックして、有料版を作るとき見返してください。


ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?