Amazonで商品を見る セール会場へ

WordPress│自作プラグインにGumroadライセンス認証を実装する方法

WordPressの有料プラグインをGumroadで販売する場合、ライセンス認証の仕組みをどう作るかが課題になります。

EDD(Easy Digital Downloads)やLemon Squeezyなどのサービスを使う方法もありますが、この記事ではGumroadの販売機能+自前のWordPress REST APIサーバーだけで完結するシンプルな構成を紹介します。

ツールを販売できるGumroad(ガムロード)では購入したユーザーごとにライセンスキーが発行できます。

これをライセンスサーバー(WordPressを入れてプラグインを入れるだけ)で、定期的に自動認証してプラグインを使えるようにします。

Gumroadで購入したユーザーのライセンスキーをライセンスサーバーで定期的に自動認証する仕組みのイメージ。

外部のライセンス管理サービスへの月額費用は一切不要です。

目次

著者

WEB制作をしているデジタルノマド
WordPressのカスタマイズが好きで、色々と自作しています。

WordPressのカスタマイズに困ったらご相談ください!

この記事で実装する構成

  • 購入者がGumroadで購入
    • ライセンスキーが発行される
  • プラグインをインストール → ライセンスキーを入力
    • WordPress REST APIサーバーに送信
  • Gumroad APIでライセンス検証 + MySQLにサイト登録
    • 最大3サイトまで管理
  • 有料機能が使えるようになる

技術スタック

  • 販売プラットフォーム: Gumroad
  • ライセンス検証: Gumroad License Verify API(無料・APIキー不要)
  • バックエンド: WordPress REST API(シンレンタルサーバー)
  • データ保存: MySQL(WordPressのDB)
  • クライアント: 販売プラグイン側PHP

それではやり方を詳しく解説します。

実際の記録

実際の導入の記録です。

Gumroadに商品を登録

商品登録時にLicense Keyを追加して、自分のAPIキー(xxxxxxxxxxxxxxxxxxx-X==)をメモしておきましょう。

Gumroadでの商品登録時にライセンスキーの項目を追加し、APIキーを確認する画面の様子。
自作プラグインにGumroadライセンス認証を実装する手順のイメージ。

ライセンスサーバーの環境構築

サブドメイン等でライセンス認証用にWordPressサイトを立ち上げます。

あとは下記のコード(my-license-server.php)をzipに圧縮して、プラグインとして有効化します。

WordPressの管理画面>設定>License ServerからGumroad Product ID(xxxxxxxxxxxxxxxxxxx-X==)を登録して保存します。

WordPressの設定画面にあるライセンスサーバー項目で、GumroadのプロダクトIDを登録して保存する様子。

自作プラグインの購入

Gumroadで自作プラグインを公開。

0円にして、自分のプラグインをGumroadから購入しましょう。

そして自分だけに発行されるライセンスキーをコピーします。

Gumroadで自作プラグインを購入し、発行されたライセンスキーを確認・取得する画面の様子。

購入した自作プラグインを有効化。

自作プラグイン設定からライセンスキーを入れて保存。

最後にこのライセンスを有効化しましょう。

自作プラグインの設定画面でライセンスキーを入力し、ライセンスを有効化する手順を示す画面。

今回のプラグインは3ドメインまで使えるという設定にしました。

データベースや認証でのハマりどころも書いていますので、知見があるうえでAI等を使って実装したほうが早いです。

実際のライセンスサーバーや自作プラグインで使ったコードも共有します。

【最重要】事前に知っておくべきGumroadの罠

実装を始める前に、必ずこれを把握してください。

ここを誤解すると丸一日ハマります(実際にハマりました)。

GumroadのProduct IDは「2種類」ある

Gumroadの管理画面を見ると、一見似たような値が2つ存在します。

名称値の例用途
Permalink(URLスラッグ)hogehoge商品URL用。verify APIでは使えない
Product ID(API用)xxxxxxxxxxxxxxxxxxx-X==verify APIで使う。==まで含む

Product IDの場所: Gumroad管理画面 → 商品を開く → 右側に「Use your product ID to verify licenses through the API.」と書かれた欄の下に表示されている文字列です。

「hogehoge」のようなシンプルなURLスラッグをProduct IDとしてGumroad APIに送ると、That license does not exist for the provided product. というエラーが返ってきます。

xxxxxxxxxxxxxxxxxxx-X== のような==で終わるBase64形式の文字列が正しいProduct IDです。

Gumroad License Verify APIはAPIキー不要

以下のように、product_idとlicense_keyだけで検証できます。

curl -X POST https://api.gumroad.com/v2/licenses/verify \
  -d "product_id=xxxxxxxxxxxxxxxxxxx-X==" \
  -d "license_key=XXXX-XXXX-XXXX-XXXX" \
  -d "increment_uses_count=false"

increment_uses_count=false を指定することでGumroad側の使用カウントを増やさず、サイト数管理は自前で行います。

Step 1: ライセンスサーバー用WordPressを設置

サブドメインを追加してWordPressをインストール

レンタルサーバーの場合、管理パネルから api.example.com のようなサブドメインを追加し、WordPress簡単インストールでWordPressを入れます。

インストール後に必ず行うこと:設定 → パーマリンク → 「投稿名」に変更して保存。

これをしないとREST APIのルートが認識されません(rest_no_route エラーの原因になります)。

ライセンスサーバープラグインをアップロード

以下の構成でプラグインファイルをアップロードします。

/wp-content/plugins/My-license-server/
    └── My-license-server.php

WordPress管理画面 → プラグイン → 「MY License Server」を有効化すると、以下の3テーブルが自動作成されます。

テーブル名用途
wp_mls_licensesライセンスキー管理(ハッシュ化して保存)
wp_mls_activations有効化サイト管理
wp_mls_logsデバッグ用ログ

ライセンスキーの平文はDBに保存しません。

SHA-256ハッシュのみ保存し、ログには末尾4文字だけ残します。

Step 2: Gumroad Product IDをDBに登録

WordPress管理画面 → 設定 → License Server を開き、Gumroad Product IDに xxxxxxxxxxxxxxxxxxx-X==(==まで含む)を入力して保存します。

⚠️ セキュリティプラグインで保存できない場合

XO SecurityなどのセキュリティプラグインがWordPressの options.php へのPOSTリクエストに干渉することがあります。

その場合は管理画面での保存が効かないため、phpMyAdminで直接登録します。

phpMyAdmin → 対象DB → SQLタブで以下を実行:

INSERT INTO wp_options (option_name, option_value, autoload)
VALUES ('mls_product_id', 'xxxxxxxxxxxxxxxxxxx-X==', 'yes')
ON DUPLICATE KEY UPDATE option_value = 'xxxxxxxxxxxxxxxxxxx-X==';

登録確認:

SELECT option_name, option_value FROM wp_options
WHERE option_name = 'mls_product_id';

Step 3: 動作確認(curl)

Windowsの場合は1行で実行

Windowsのコマンドプロンプトはバックスラッシュ \ による改行ができません。

以下のように1行で貼り付けて実行してください。

curl -X POST https://api.example.com/wp-json/mls/v1/license -H "Content-Type: application/json" -d "{\"action\":\"activate\",\"license_key\":\"XXXX-XXXX-XXXX-XXXX\",\"site_url\":\"https://test.example.com\",\"product_id\":\"xxxxxxxxxxxxxxxxxxx-X==\",\"plugin_version\":\"1.0.0\"}"

期待するレスポンス

{
  "ok": true,
  "status": "active",
  "message": "activated",
  "site_count": 1,
  "max_sites": 3,
  "domain": "test.example.com"
}

Mac / Linuxの場合

curl -X POST https://api.example.com/wp-json/mls/v1/license \
  -H "Content-Type: application/json" \
  -d '{
    "action": "activate",
    "license_key": "XXXX-XXXX-XXXX-XXXX",
    "site_url": "https://test.example.com",
    "product_id": "xxxxxxxxxxxxxxxxxxx-X==",
    "plugin_version": "1.0.0"
  }'

Step 4: 販売プラグインへの組み込み

ライセンスAPIのURLをデフォルト値として埋め込む

購入者がURLを手入力しなくて済むよう、設定画面のライセンスAPI URL欄にデフォルト値を設定します。

// 変更前
value="<?php echo esc_attr((string) ($settings['license_api_url'] ?? '')); ?>"

// 変更後(?: でフォールバック)
value="<?php echo esc_attr((string) ($settings['license_api_url'] 
    ?: 'https://api.example.com/wp-json/mls/v1/license')); ?>"

有料機能をライセンスでガードする

認証していない場合に機能を止めるには、AJAXハンドラの共通バリデーション関数にチェックを追加します。

function amu_ai_validate_rewrite_request(): void
{
    check_ajax_referer('amu_ai_rewriter', 'nonce');

    // ライセンスチェックを追加
    if ( ! function_exists('amu_ai_license_is_recently_valid') 
         || ! amu_ai_license_is_recently_valid() ) {
        wp_send_json_error( [
            'message' => 'ライセンスが有効ではありません。ライセンス設定を確認してください。'
        ], 403 );
    }

    // 以下は既存のコード
    $post_id = isset($_POST['postId']) ? (int) $_POST['postId'] : 0;
    if ($post_id <= 0 || ! current_user_can('edit_post', $post_id)) {
        wp_send_json_error(['message' => 'Permission denied.'], 403);
    }
}

リライト・記事生成・機会プランナーなど、AIを使う全機能の共通バリデーション関数にこれを追加すれば一括でガードできます。

ZIPをパッケージする際の注意

Windowsで作成したZIPはパス区切り文字がバックスラッシュ(\)になり、シンレンタルサーバー(Linux)で展開するとフォルダ構造が壊れてプラグインが動きません。

Pythonで正しく再パッケージします。

import zipfile

with zipfile.ZipFile('original.zip') as zin, \
     zipfile.ZipFile('fixed.zip', 'w', zipfile.ZIP_DEFLATED) as zout:
    for item in zin.infolist():
        new_name = item.filename.replace('\\', '/')  # バックスラッシュを修正
        zout.writestr(new_name, zin.read(item.filename))

ライセンスサーバーの仕組み

API仕様

3つのエンドポイントをすべて同一URL(POST /wp-json/mls/v1/license)に集約し、action パラメータで分岐します。

action処理
activateライセンス検証 → サイト登録(最大3サイト)
validate定期的な有効性確認(24時間ごと)
deactivateサイトの解除(別サイトへ移行可能)

ライセンス認証の流れ

  1. クライアントがライセンスキー+サイトURLを送信
  2. サーバーがGumroad APIでライセンス検証
  3. 返金・紛争のライセンスは自動で無効化
  4. 有効なら wp_mls_activations にサイトを登録
  5. 3サイト超えたら limit_exceeded を返す

キャッシュと猶予期間

毎回サーバーに問い合わせると負荷がかかるため、クライアント側でキャッシュします。

  • 24時間以内: 前回の結果をそのまま使用(サーバーへの通信なし)
  • 24時間経過: バックグラウンドで再検証
  • 通信失敗時: 前回成功から7日間は機能を維持する猶予期間あり

ライセンス管理(運営者向け)

不正利用者のライセンスを失効させる

UPDATE wp_mls_licenses
SET status = 'revoked'
WHERE license_key_last4 = '1100';  -- ライセンスキー末尾4文字で特定

特定サイトを強制解除する

UPDATE wp_mls_activations
SET status = 'inactive', deactivated_at = NOW()
WHERE domain = 'problem-site.com';

返金対応について

Gumroad APIでライセンス検証する際、レスポンスに refunded: true が含まれていれば自動で無効化されます。

APIキー不要で動作するため、返金時の手動対応は不要です。

なお、デジタルコンテンツは規約に「返金不可」と明記しておくことを推奨します。

Gumroadの商品設定でも「No refunds」を選択できます。

トラブルシューティング

エラー原因対処
rest_no_routeパーマリンク設定が「基本」設定→パーマリンク→「投稿名」で再保存
That license does not exist for the provided product.URLスラッグ(hogehoge)をProduct IDとして使っている==で終わるBase64形式のProduct IDを使う
License API response was invalid.レスポンスがJSONでないcurl -v でHTTPステータスを確認
管理画面の設定が保存されないXO Security等が干渉phpMyAdminで直接INSERT
ZIPインストール後にエラーWindowsバックスラッシュ問題PythonでZIPを再パッケージ

実際のコード

実際のコードです。

参考にしてください。

ライセンスサーバー用プラグイン

<?php
/**
 * Plugin Name: Motoki License Server
 * Description: Gumroad連携WordPress有料プラグイン用ライセンス管理サーバー
 * Version: 1.0.0
 * Author: MOTOKI合同会社
 */

defined( 'ABSPATH' ) || exit;

define( 'MLS_VERSION', '1.0.0' );
define( 'MLS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

// ─────────────────────────────────────────────
// 有効化時: テーブル作成
// ─────────────────────────────────────────────
register_activation_hook( __FILE__, 'mls_activate' );
function mls_activate() {
    mls_create_tables();
}

function mls_create_tables() {
    global $wpdb;
    $charset = $wpdb->get_charset_collate();

    // licenses テーブル
    $sql_licenses = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}mls_licenses (
        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        license_key_hash VARCHAR(64)  NOT NULL,
        license_key_last4 CHAR(4)     NOT NULL,
        product_id      VARCHAR(100)  NOT NULL,
        status          ENUM('active','revoked','invalid') NOT NULL DEFAULT 'active',
        max_sites       TINYINT UNSIGNED NOT NULL DEFAULT 3,
        gumroad_email   VARCHAR(255)  NOT NULL DEFAULT '',
        gumroad_purchase_id VARCHAR(100) NOT NULL DEFAULT '',
        created_at      DATETIME      NOT NULL,
        updated_at      DATETIME      NOT NULL,
        last_verified_at DATETIME     NULL,
        PRIMARY KEY (id),
        UNIQUE KEY uq_hash_product (license_key_hash, product_id),
        KEY idx_hash (license_key_hash)
    ) $charset;";

    // activations テーブル
    $sql_activations = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}mls_activations (
        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        license_key_hash VARCHAR(64)  NOT NULL,
        site_hash       VARCHAR(64)   NOT NULL,
        domain          VARCHAR(255)  NOT NULL,
        normalized_site VARCHAR(255)  NOT NULL,
        status          ENUM('active','inactive') NOT NULL DEFAULT 'active',
        activated_at    DATETIME      NOT NULL,
        deactivated_at  DATETIME      NULL,
        last_seen_at    DATETIME      NOT NULL,
        PRIMARY KEY (id),
        UNIQUE KEY uq_license_site (license_key_hash, site_hash),
        KEY idx_license (license_key_hash)
    ) $charset;";

    // logs テーブル
    $sql_logs = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}mls_logs (
        id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        timestamp       DATETIME      NOT NULL,
        action          VARCHAR(20)   NOT NULL,
        license_key_last4 CHAR(4)    NOT NULL DEFAULT '',
        domain          VARCHAR(255)  NOT NULL DEFAULT '',
        result          VARCHAR(20)   NOT NULL,
        message         TEXT          NOT NULL,
        PRIMARY KEY (id),
        KEY idx_timestamp (timestamp)
    ) $charset;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql_licenses );
    dbDelta( $sql_activations );
    dbDelta( $sql_logs );
}

// ─────────────────────────────────────────────
// REST API 登録
// ─────────────────────────────────────────────
add_action( 'rest_api_init', 'mls_register_routes' );
function mls_register_routes() {
    register_rest_route( 'mls/v1', '/license', [
        'methods'             => 'POST',
        'callback'            => 'mls_handle_request',
        'permission_callback' => '__return_true', // 認証はアプリ側で行う
    ] );
}

// ─────────────────────────────────────────────
// メインハンドラ
// ─────────────────────────────────────────────
function mls_handle_request( WP_REST_Request $request ) {
    $body = $request->get_json_params();
    if ( empty( $body ) ) {
        // form-urlencoded フォールバック
        $body = $request->get_params();
    }

    $action = sanitize_text_field( $body['action'] ?? '' );

    switch ( $action ) {
        case 'activate':
            return mls_action_activate( $body );
        case 'validate':
            return mls_action_validate( $body );
        case 'deactivate':
            return mls_action_deactivate( $body );
        default:
            return mls_json_error( 'invalid_action', '不正なアクションです', 400 );
    }
}

// ─────────────────────────────────────────────
// activate
// ─────────────────────────────────────────────
function mls_action_activate( array $body ) {
    // バリデーション
    $v = mls_validate_input( $body, [ 'license_key', 'site_url' ] );
    if ( is_wp_error( $v ) ) {
        return mls_json_error( 'validation_error', $v->get_error_message(), 400 );
    }

    $license_key = $body['license_key'];
    $product_id  = mls_get_product_id( $body );
    $site_url    = $body['site_url'];

    $normalized  = mls_normalize_site_url( $site_url );
    $key_hash    = mls_sha256( $license_key );
    $site_hash   = mls_sha256( $normalized );
    $key_last4   = substr( $license_key, -4 );

    // Gumroad verify
    $gumroad = mls_verify_gumroad( $license_key, $product_id );
    if ( ! $gumroad['valid'] ) {
        mls_write_log( 'activate', $key_last4, $normalized, 'fail', 'Gumroad invalid: ' . $gumroad['reason'] );
        return mls_json_error( 'license_invalid', 'ライセンスが無効です', 403 );
    }

    global $wpdb;
    $now = current_time( 'mysql' );

    // licenses upsert
    $license = mls_find_license( $key_hash, $product_id );
    if ( ! $license ) {
        $wpdb->insert( $wpdb->prefix . 'mls_licenses', [
            'license_key_hash'    => $key_hash,
            'license_key_last4'   => $key_last4,
            'product_id'          => $product_id,
            'status'              => 'active',
            'max_sites'           => 3,
            'gumroad_email'       => $gumroad['email'],
            'gumroad_purchase_id' => $gumroad['purchase_id'],
            'created_at'          => $now,
            'updated_at'          => $now,
            'last_verified_at'    => $now,
        ] );
        $license = mls_find_license( $key_hash, $product_id );
    } else {
        // revoked チェック
        if ( $license->status === 'revoked' ) {
            mls_write_log( 'activate', $key_last4, $normalized, 'fail', 'License revoked' );
            return mls_json_error( 'license_revoked', 'このライセンスは無効化されています', 403 );
        }
        // last_verified_at 更新
        $wpdb->update(
            $wpdb->prefix . 'mls_licenses',
            [ 'last_verified_at' => $now, 'updated_at' => $now, 'gumroad_email' => $gumroad['email'] ],
            [ 'id' => $license->id ]
        );
    }

    $max_sites = (int) $license->max_sites;

    // 同一サイトが既にアクティブ → 冪等成功
    $existing = mls_find_active_activation( $key_hash, $site_hash );
    if ( $existing ) {
        $wpdb->update(
            $wpdb->prefix . 'mls_activations',
            [ 'last_seen_at' => $now ],
            [ 'id' => $existing->id ]
        );
        $count = mls_count_active_activations( $key_hash );
        mls_write_log( 'activate', $key_last4, $normalized, 'ok', 'Already active (idempotent)' );
        return rest_ensure_response( [
            'ok'        => true,
            'status'    => 'active',
            'message'   => 'already_active',
            'site_count' => $count,
            'max_sites' => $max_sites,
            'domain'    => $normalized,
        ] );
    }

    // 使用数チェック
    $count = mls_count_active_activations( $key_hash );
    if ( $count >= $max_sites ) {
        mls_write_log( 'activate', $key_last4, $normalized, 'fail', "Limit exceeded: {$count}/{$max_sites}" );
        return rest_ensure_response( [
            'ok'        => false,
            'status'    => 'limit_exceeded',
            'message'   => '有効化できるサイト数の上限に達しています',
            'site_count' => $count,
            'max_sites' => $max_sites,
        ] );
    }

    // activation 作成(UPSERT: inactive行があれば再active化)
    $row = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mls_activations WHERE license_key_hash=%s AND site_hash=%s",
        $key_hash, $site_hash
    ) );

    if ( $row ) {
        $wpdb->update(
            $wpdb->prefix . 'mls_activations',
            [ 'status' => 'active', 'activated_at' => $now, 'deactivated_at' => null, 'last_seen_at' => $now, 'domain' => $normalized ],
            [ 'id' => $row->id ]
        );
    } else {
        $wpdb->insert( $wpdb->prefix . 'mls_activations', [
            'license_key_hash' => $key_hash,
            'site_hash'        => $site_hash,
            'domain'           => $normalized,
            'normalized_site'  => $normalized,
            'status'           => 'active',
            'activated_at'     => $now,
            'deactivated_at'   => null,
            'last_seen_at'     => $now,
        ] );
    }

    $count = mls_count_active_activations( $key_hash );
    mls_write_log( 'activate', $key_last4, $normalized, 'ok', "Activated. {$count}/{$max_sites}" );

    return rest_ensure_response( [
        'ok'        => true,
        'status'    => 'active',
        'message'   => 'activated',
        'site_count' => $count,
        'max_sites' => $max_sites,
        'domain'    => $normalized,
    ] );
}

// ─────────────────────────────────────────────
// validate
// ─────────────────────────────────────────────
function mls_action_validate( array $body ) {
    $v = mls_validate_input( $body, [ 'license_key', 'site_url' ] );
    if ( is_wp_error( $v ) ) {
        return mls_json_error( 'validation_error', $v->get_error_message(), 400 );
    }

    $license_key = $body['license_key'];
    $product_id  = mls_get_product_id( $body );
    $site_url    = $body['site_url'];

    $normalized = mls_normalize_site_url( $site_url );
    $key_hash   = mls_sha256( $license_key );
    $site_hash  = mls_sha256( $normalized );
    $key_last4  = substr( $license_key, -4 );

    // Gumroad verify
    $gumroad = mls_verify_gumroad( $license_key, $product_id );
    if ( ! $gumroad['valid'] ) {
        mls_write_log( 'validate', $key_last4, $normalized, 'fail', 'Gumroad invalid: ' . $gumroad['reason'] );
        return rest_ensure_response( [
            'ok'     => true,
            'valid'  => false,
            'status' => 'license_invalid',
        ] );
    }

    // ライセンス行チェック
    $license = mls_find_license( $key_hash, $product_id );
    if ( ! $license || $license->status === 'revoked' ) {
        mls_write_log( 'validate', $key_last4, $normalized, 'fail', 'License not found or revoked' );
        return rest_ensure_response( [
            'ok'     => true,
            'valid'  => false,
            'status' => 'license_revoked',
        ] );
    }

    $max_sites = (int) $license->max_sites;
    $count     = mls_count_active_activations( $key_hash );

    // last_verified_at 更新
    global $wpdb;
    $now = current_time( 'mysql' );
    $wpdb->update(
        $wpdb->prefix . 'mls_licenses',
        [ 'last_verified_at' => $now, 'updated_at' => $now ],
        [ 'id' => $license->id ]
    );

    // activation チェック
    $activation = mls_find_active_activation( $key_hash, $site_hash );
    if ( $activation ) {
        $wpdb->update(
            $wpdb->prefix . 'mls_activations',
            [ 'last_seen_at' => $now ],
            [ 'id' => $activation->id ]
        );
        mls_write_log( 'validate', $key_last4, $normalized, 'ok', 'Valid' );
        return rest_ensure_response( [
            'ok'        => true,
            'valid'     => true,
            'status'    => 'active',
            'site_count' => $count,
            'max_sites' => $max_sites,
            'domain'    => $normalized,
        ] );
    }

    mls_write_log( 'validate', $key_last4, $normalized, 'fail', 'Not activated for this site' );
    return rest_ensure_response( [
        'ok'        => true,
        'valid'     => false,
        'status'    => 'not_activated_for_this_site',
        'site_count' => $count,
        'max_sites' => $max_sites,
    ] );
}

// ─────────────────────────────────────────────
// deactivate
// ─────────────────────────────────────────────
function mls_action_deactivate( array $body ) {
    $v = mls_validate_input( $body, [ 'license_key', 'site_url' ] );
    if ( is_wp_error( $v ) ) {
        return mls_json_error( 'validation_error', $v->get_error_message(), 400 );
    }

    $license_key = $body['license_key'];
    $product_id  = mls_get_product_id( $body );
    $site_url    = $body['site_url'];

    $normalized = mls_normalize_site_url( $site_url );
    $key_hash   = mls_sha256( $license_key );
    $site_hash  = mls_sha256( $normalized );
    $key_last4  = substr( $license_key, -4 );

    // Gumroad verify(解除時も確認)
    $gumroad = mls_verify_gumroad( $license_key, $product_id );
    if ( ! $gumroad['valid'] ) {
        mls_write_log( 'deactivate', $key_last4, $normalized, 'fail', 'Gumroad invalid' );
        return mls_json_error( 'license_invalid', 'ライセンスが無効です', 403 );
    }

    global $wpdb;
    $now = current_time( 'mysql' );

    $activation = mls_find_active_activation( $key_hash, $site_hash );
    if ( $activation ) {
        $wpdb->update(
            $wpdb->prefix . 'mls_activations',
            [ 'status' => 'inactive', 'deactivated_at' => $now ],
            [ 'id' => $activation->id ]
        );
    }

    $count = mls_count_active_activations( $key_hash );
    $license = mls_find_license( $key_hash, $product_id );
    $max_sites = $license ? (int) $license->max_sites : 3;

    mls_write_log( 'deactivate', $key_last4, $normalized, 'ok', "Deactivated. remaining: {$count}/{$max_sites}" );

    return rest_ensure_response( [
        'ok'        => true,
        'status'    => 'deactivated',
        'site_count' => $count,
        'max_sites' => $max_sites,
    ] );
}

// ─────────────────────────────────────────────
// Gumroad verify
// ─────────────────────────────────────────────
function mls_verify_gumroad( string $license_key, string $product_id ): array {
    // Gumroad verify APIに product_id(Base64形式)を送る。APIキー不要。
    $response = wp_remote_post( 'https://api.gumroad.com/v2/licenses/verify', [
        'timeout' => 10,
        'body'    => [
            'product_id'           => $product_id,
            'license_key'          => $license_key,
            'increment_uses_count' => 'false',
        ],
    ] );

    if ( is_wp_error( $response ) ) {
        return [ 'valid' => false, 'reason' => 'network_error', 'email' => '', 'purchase_id' => '' ];
    }

    $code = wp_remote_retrieve_response_code( $response );
    $data = json_decode( wp_remote_retrieve_body( $response ), true );

    if ( $code !== 200 || empty( $data['success'] ) ) {
        $reason = $data['message'] ?? 'invalid';
        return [ 'valid' => false, 'reason' => $reason, 'email' => '', 'purchase_id' => '' ];
    }

    $purchase = $data['purchase'] ?? [];

    // 返金・紛争チェック
    if ( ! empty( $purchase['refunded'] ) || ! empty( $purchase['disputed'] ) || ! empty( $purchase['chargebacked'] ) ) {
        return [ 'valid' => false, 'reason' => 'refunded_or_disputed', 'email' => '', 'purchase_id' => '' ];
    }

    return [
        'valid'       => true,
        'reason'      => '',
        'email'       => sanitize_email( $purchase['email'] ?? '' ),
        'purchase_id' => sanitize_text_field( $purchase['id'] ?? $purchase['sale_id'] ?? '' ),
    ];
}

// ─────────────────────────────────────────────
// ユーティリティ関数
// ─────────────────────────────────────────────

/** site_url を正規化する */
function mls_normalize_site_url( string $url ): string {
    $url = strtolower( trim( $url ) );
    // schemeがなければ付加してparse_urlを通す
    if ( ! preg_match( '#^https?://#', $url ) ) {
        $url = 'https://' . $url;
    }
    $parsed = parse_url( $url );
    $host   = $parsed['host'] ?? $url;
    // www. 除去
    $host   = preg_replace( '/^www\./', '', $host );
    return $host;
}

/** SHA-256ハッシュ */
function mls_sha256( string $value ): string {
    return hash( 'sha256', $value );
}

/** ライセンス行取得 */
function mls_find_license( string $key_hash, string $product_id ) {
    global $wpdb;
    return $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mls_licenses WHERE license_key_hash=%s AND product_id=%s LIMIT 1",
        $key_hash, $product_id
    ) );
}

/** アクティブなactivation行取得 */
function mls_find_active_activation( string $key_hash, string $site_hash ) {
    global $wpdb;
    return $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mls_activations WHERE license_key_hash=%s AND site_hash=%s AND status='active' LIMIT 1",
        $key_hash, $site_hash
    ) );
}

/** アクティブなactivation数 */
function mls_count_active_activations( string $key_hash ): int {
    global $wpdb;
    return (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$wpdb->prefix}mls_activations WHERE license_key_hash=%s AND status='active'",
        $key_hash
    ) );
}

/** ログ書き込み */
function mls_write_log( string $action, string $key_last4, string $domain, string $result, string $message ) {
    global $wpdb;
    $wpdb->insert( $wpdb->prefix . 'mls_logs', [
        'timestamp'         => current_time( 'mysql' ),
        'action'            => $action,
        'license_key_last4' => $key_last4,
        'domain'            => $domain,
        'result'            => $result,
        'message'           => $message,
    ] );

    // ログが10000行を超えたら古い行を削除
    $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mls_logs" );
    if ( $count > 10000 ) {
        $wpdb->query( "DELETE FROM {$wpdb->prefix}mls_logs ORDER BY id ASC LIMIT 1000" );
    }
}

/** 入力バリデーション */
function mls_validate_input( array $body, array $required_keys ) {
    // product_slug → product_id へ正規化(amu-ai-publisher クライアント対応)
    if ( empty( $body['product_id'] ) && ! empty( $body['product_slug'] ) ) {
        $body['product_id'] = $body['product_slug'];
    }

    foreach ( $required_keys as $key ) {
        if ( empty( $body[ $key ] ) ) {
            return new WP_Error( 'missing_field', "必須パラメータが不足しています: {$key}" );
        }
    }

    // product_idの照合はGumroad verifyに任せるためここではチェックしない
    return true;
}

/** Gumroad verify用のproduct_idを取得(DB設定値を優先) */
function mls_get_product_id( array $body ): string {
    // DBに設定されたProduct IDを優先(Gumroad verify用)
    $configured = get_option( 'mls_product_id', '' );
    if ( $configured ) {
        return trim( $configured );
    }
    // フォールバック: リクエストのproduct_id or product_slug
    if ( ! empty( $body['product_id'] ) ) {
        return sanitize_text_field( $body['product_id'] );
    }
    return sanitize_text_field( $body['product_slug'] ?? '' );
}

/** エラーレスポンス */
function mls_json_error( string $code, string $message, int $status = 400 ) {
    return new WP_REST_Response( [
        'ok'      => false,
        'status'  => $code,
        'message' => $message,
    ], $status );
}

// ─────────────────────────────────────────────
// 管理画面: 設定ページ
// ─────────────────────────────────────────────
add_action( 'admin_menu', 'mls_admin_menu' );
function mls_admin_menu() {
    add_options_page(
        'License Server 設定',
        'License Server',
        'manage_options',
        'mls-settings',
        'mls_settings_page'
    );
}

add_action( 'admin_init', 'mls_register_settings' );
function mls_register_settings() {
    register_setting( 'mls_options', 'mls_product_id', [
        'sanitize_callback' => function( $val ) { return trim( (string) $val ); },
    ] );
}

function mls_settings_page() {
    global $wpdb;
    ?>
    <div class="wrap">
        <h1>License Server 設定</h1>
        <form method="post" action="options.php">
            <?php settings_fields( 'mls_options' ); ?>
            <table class="form-table">
                <tr>
                    <th>Gumroad Product ID</th>
                    <td>
                        <input type="text" name="mls_product_id"
                            value="<?php echo esc_attr( get_option( 'mls_product_id' ) ); ?>"
                            class="regular-text" />
                        <p class="description">Gumroad管理画面の「Use your product ID to verify licenses」のID(例: xxxxxxxxxxxxxxxxxxx-X==)</p>
                    </td>
                </tr>
            </table>
            <?php submit_button(); ?>
        </form>

        <h2>エンドポイント</h2>
        <code><?php echo esc_url( rest_url( 'mls/v1/license' ) ); ?></code>

        <h2>ライセンス一覧</h2>
        <?php
        $licenses = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}mls_licenses ORDER BY created_at DESC LIMIT 50" );
        if ( $licenses ) :
        ?>
        <table class="wp-list-table widefat striped">
            <thead><tr>
                <th>Key末尾4桁</th><th>Product ID</th><th>Status</th>
                <th>Email</th><th>使用数/上限</th><th>最終確認</th>
            </tr></thead>
            <tbody>
            <?php foreach ( $licenses as $lic ) :
                $count = mls_count_active_activations( $lic->license_key_hash );
            ?>
                <tr>
                    <td>****<?php echo esc_html( $lic->license_key_last4 ); ?></td>
                    <td><?php echo esc_html( $lic->product_id ); ?></td>
                    <td><?php echo esc_html( $lic->status ); ?></td>
                    <td><?php echo esc_html( $lic->gumroad_email ); ?></td>
                    <td><?php echo esc_html( $count . '/' . $lic->max_sites ); ?></td>
                    <td><?php echo esc_html( $lic->last_verified_at ); ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
        <?php else : ?>
        <p>まだライセンスデータがありません。</p>
        <?php endif; ?>

        <h2>最近のログ</h2>
        <?php
        $logs = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}mls_logs ORDER BY id DESC LIMIT 50" );
        if ( $logs ) :
        ?>
        <table class="wp-list-table widefat striped">
            <thead><tr>
                <th>日時</th><th>Action</th><th>Key末尾4桁</th><th>Domain</th><th>Result</th><th>Message</th>
            </tr></thead>
            <tbody>
            <?php foreach ( $logs as $log ) : ?>
                <tr>
                    <td><?php echo esc_html( $log->timestamp ); ?></td>
                    <td><?php echo esc_html( $log->action ); ?></td>
                    <td>****<?php echo esc_html( $log->license_key_last4 ); ?></td>
                    <td><?php echo esc_html( $log->domain ); ?></td>
                    <td style="color:<?php echo $log->result === 'ok' ? 'green' : 'red'; ?>">
                        <?php echo esc_html( $log->result ); ?></td>
                    <td><?php echo esc_html( $log->message ); ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
        <?php else : ?>
        <p>ログはありません。</p>
        <?php endif; ?>
    </div>
    <?php
}

自作プラグイン

認証部分の実装だけ、コードを公開します。

<?php
/**
 * ライセンス認証クライアント(公開サンプル)
 *
 * 販売プラグイン側に組み込む認証処理です。
 * ライセンスサーバーのエンドポイントに activate / validate / deactivate を送信します。
 *
 * File: inc/license-client.php
 */

declare(strict_types=1);

if (! defined('ABSPATH')) {
    exit;
}

// ─────────────────────────────────────────────
// ライセンス状態の管理
// ─────────────────────────────────────────────

function amu_ai_license_default_state(): array
{
    return [
        'status'     => 'inactive',
        'last_check' => '',
        'domain'     => '',
        'site_count' => 0,
        'max_sites'  => 0,
        'message'    => '',
    ];
}

function amu_ai_license_get_state(): array
{
    $state = get_option('amu_ai_license_state', []);
    return wp_parse_args(is_array($state) ? $state : [], amu_ai_license_default_state());
}

function amu_ai_license_update_state(array $state): void
{
    $current = amu_ai_license_get_state();
    update_option('amu_ai_license_state', array_merge($current, $state), false);
}

function amu_ai_license_reset_state(): void
{
    update_option('amu_ai_license_state', amu_ai_license_default_state(), false);
}

// ─────────────────────────────────────────────
// 設定値の取得
// ─────────────────────────────────────────────

/** ライセンスサーバーのエンドポイントURL */
function amu_ai_license_get_api_url(): string
{
    $settings = get_option('amu_ai_settings', []);
    return untrailingslashit((string) ($settings['license_api_url'] ?? ''));
}

/** このサイトのドメイン(正規化済み) */
function amu_ai_license_get_current_domain(): string
{
    $host = wp_parse_url(home_url('/'), PHP_URL_HOST);
    $host = is_string($host) ? strtolower($host) : '';
    return preg_replace('/^www\./', '', $host) ?? $host;
}

// ─────────────────────────────────────────────
// ライセンスサーバーへのリクエスト
// ─────────────────────────────────────────────

/**
 * ライセンスサーバーに action を送信する共通関数
 *
 * @param string $action  activate / validate / deactivate
 * @return array|WP_Error レスポンスJSON配列 or WP_Error
 */
function amu_ai_license_request(string $action)
{
    $settings    = get_option('amu_ai_settings', []);
    $license_key = (string) ($settings['license_key'] ?? '');
    $api_url     = amu_ai_license_get_api_url();

    if ($license_key === '' || $api_url === '') {
        return new WP_Error('amu_ai_license_missing_config', 'ライセンスキーまたはAPIのURLが未設定です。');
    }

    $response = wp_remote_post($api_url, [
        'timeout' => 10,
        'headers' => ['Content-Type' => 'application/json; charset=utf-8'],
        'body'    => wp_json_encode([
            'action'         => $action,
            'product_slug'   => 'your-plugin-slug', // プラグインのスラッグに変更
            'license_key'    => $license_key,
            'site_url'       => home_url('/'),
            'plugin_version' => '1.0.0', // プラグインのバージョンに変更
        ]),
    ]);

    if (is_wp_error($response)) {
        return $response;
    }

    $code = (int) wp_remote_retrieve_response_code($response);
    $body = (string) wp_remote_retrieve_body($response);
    $data = json_decode($body, true);

    if ($code < 200 || $code >= 300 || ! is_array($data)) {
        return new WP_Error('amu_ai_license_invalid_response', 'ライセンスサーバーからの応答が不正です。');
    }

    return $data;
}

// ─────────────────────────────────────────────
// レスポンスをDBに保存
// ─────────────────────────────────────────────

function amu_ai_license_store_response(array $data, string $fallback_status = 'inactive'): void
{
    amu_ai_license_update_state([
        'status'     => (string) ($data['status'] ?? $fallback_status),
        'last_check' => current_time('mysql'),
        'domain'     => (string) ($data['domain'] ?? amu_ai_license_get_current_domain()),
        'site_count' => (int) ($data['site_count'] ?? 0),
        'max_sites'  => (int) ($data['max_sites'] ?? 0),
        'message'    => (string) ($data['message'] ?? ''),
    ]);
}

// ─────────────────────────────────────────────
// 有効性チェック
// ─────────────────────────────────────────────

/**
 * ライセンスが有効かどうかを判定する
 *
 * - status が 'active' であること
 * - 最終確認から7日以内であること(通信失敗時の猶予期間)
 *
 * 有料機能のガードに使う: if ( ! amu_ai_license_is_recently_valid() ) { return; }
 */
function amu_ai_license_is_recently_valid(): bool
{
    $state = amu_ai_license_get_state();

    if (($state['status'] ?? '') !== 'active') {
        return false;
    }

    $last_check = strtotime((string) ($state['last_check'] ?? ''));
    if (! $last_check) {
        return false;
    }

    // 7日以内なら有効(ライセンスサーバーが一時的にダウンしても猶予を持つ)
    return $last_check >= (time() - DAY_IN_SECONDS * 7);
}

// ─────────────────────────────────────────────
// 管理画面のアクションハンドラ
// ─────────────────────────────────────────────

/** 有効化ボタン押下 */
function amu_ai_license_handle_activate(): void
{
    if (! current_user_can('manage_options')) {
        wp_die('Permission denied.');
    }
    check_admin_referer('amu_ai_license_activate');

    $response = amu_ai_license_request('activate');

    if (is_wp_error($response)) {
        amu_ai_license_set_notice('error', $response->get_error_message());
    } else {
        amu_ai_license_store_response($response, 'inactive');
        amu_ai_license_set_notice('success', (string) ($response['message'] ?? 'ライセンスを有効化しました。'));
    }

    wp_safe_redirect(wp_get_referer() ?: admin_url('options-general.php?page=your-plugin-license'));
    exit;
}
add_action('admin_post_amu_ai_license_activate', 'amu_ai_license_handle_activate');

/** 検証ボタン押下 */
function amu_ai_license_handle_validate(): void
{
    if (! current_user_can('manage_options')) {
        wp_die('Permission denied.');
    }
    check_admin_referer('amu_ai_license_validate');

    $response = amu_ai_license_request('validate');

    if (is_wp_error($response)) {
        amu_ai_license_set_notice('error', $response->get_error_message());
    } else {
        amu_ai_license_store_response($response, ! empty($response['valid']) ? 'active' : 'inactive');
        amu_ai_license_set_notice('success', (string) ($response['message'] ?? 'ライセンス状態を確認しました。'));
    }

    wp_safe_redirect(wp_get_referer() ?: admin_url('options-general.php?page=your-plugin-license'));
    exit;
}
add_action('admin_post_amu_ai_license_validate', 'amu_ai_license_handle_validate');

/** 解除ボタン押下 */
function amu_ai_license_handle_deactivate(): void
{
    if (! current_user_can('manage_options')) {
        wp_die('Permission denied.');
    }
    check_admin_referer('amu_ai_license_deactivate');

    $response = amu_ai_license_request('deactivate');

    if (is_wp_error($response)) {
        amu_ai_license_set_notice('error', $response->get_error_message());
    } else {
        amu_ai_license_store_response($response, 'inactive');
        amu_ai_license_set_notice('success', (string) ($response['message'] ?? 'このサイトのライセンスを解除しました。'));
    }

    wp_safe_redirect(wp_get_referer() ?: admin_url('options-general.php?page=your-plugin-license'));
    exit;
}
add_action('admin_post_amu_ai_license_deactivate', 'amu_ai_license_handle_deactivate');

// ─────────────────────────────────────────────
// 管理画面の通知
// ─────────────────────────────────────────────

function amu_ai_license_set_notice(string $type, string $message): void
{
    if (! is_user_logged_in()) {
        return;
    }
    set_transient('amu_ai_license_notice_' . get_current_user_id(), [
        'type'    => $type,
        'message' => $message,
    ], 60);
}

function amu_ai_license_render_notice(): void
{
    if (! is_user_logged_in()) {
        return;
    }
    $key    = 'amu_ai_license_notice_' . get_current_user_id();
    $notice = get_transient($key);
    if (! is_array($notice) || empty($notice['message'])) {
        return;
    }
    delete_transient($key);
    $class = ($notice['type'] ?? 'success') === 'error' ? 'notice-error' : 'notice-success';
    echo '<div class="notice ' . esc_attr($class) . '"><p>' . esc_html((string) $notice['message']) . '</p></div>';
}
add_action('admin_notices', 'amu_ai_license_render_notice');

// ─────────────────────────────────────────────
// 有料機能のガード例
// ─────────────────────────────────────────────

/**
 * AJAXリクエストの共通バリデーション関数にライセンスチェックを追加する例
 *
 * function your_plugin_validate_request(): void
 * {
 *     check_ajax_referer('your_plugin_nonce', 'nonce');
 *
 *     // ライセンスチェック
 *     if ( ! amu_ai_license_is_recently_valid() ) {
 *         wp_send_json_error([
 *             'message' => 'ライセンスが有効ではありません。ライセンス設定を確認してください。'
 *         ], 403);
 *     }
 *
 *     // 以降は通常の権限チェック等
 * }
 */

まとめ

この構成のポイントは以下のとおりです。

  • 外部サービス不要: シンレンタルサーバーのWordPress+MySQLだけで完結
  • Gumroadのverify APIはAPIキー不要: product_idとlicense_keyだけで検証できる
  • product_idはURLスラッグではなくBase64形式: ここを間違えるとThat license does not existエラーになる
  • セキュリティプラグインが管理画面の設定保存を妨げることがある: phpMyAdminで直接INSERTする
  • WindowsのZIPはバックスラッシュ問題に注意: Pythonで再パッケージする

プラグインの販売規模が小さいうちは、このシンプルな構成で十分に機能します。

将来的にはGumroad Webhookと連携した即時返金対応や、seats課金連動なども追加できる拡張性も持たせています。

この記事で紹介しているコードは、GitHubのMOTOKI-LLC/WAZA-codeにもまとめています。

  • URLをコピーしました!

プラグインの設定でお困りなら

プラグインの導入・カスタマイズ代行

プラグインの選定から設定、競合の解消、動作に合わせた追加実装まで承ります。1時間 ¥8,000〜(税込)。

WAZAの有料記事のサブスクリプションも開始しました。

サービス

Service

WordPressサイトのカスタマイズのサービスに関心がありましたら、ぜひ詳細をご覧ください。

目次