軽量PHPブログエンジン Bludit で運用しているブログに、有料記事機能を自作しました。noteのように「途中まで無料で読めて、続きから有料」になるパターンです。決済は、法定通貨(PayPal 200円)と独自トークン(ERC20を20,000枚)の2レールを用意しました。
外部のペイウォールSaaSを使わず、PHPファイル3つとJSONファイル1つで完結しています。この記事では、その設計と、実装で踏んだ罠を解説します。
全体設計
構成要素は次の4つだけです。
- テーマ改修: 本文をマーカーで分割し、未購入者には無料部分だけ出す
- 決済API (paywall.php): 購入の記録・検証・解錠
- 共通ライブラリ (lib.php): 購入台帳・Cookie・PayPal照合・オンチェーン照合
- 購入台帳: JSONファイル(.htaccessで外部読み取り拒否)
データベースは使いません。低頻度の購入記録なら、flockで保護したJSONファイルで十分です。
1. 「途中まで無料」はサーバーサイドで分割する
記事本文に HTMLコメントのマーカーを1行入れます。
(ここまで無料の本文)
<!--paywall-->
(ここから有料の本文)
テーマ側(記事テンプレート)で、このマーカーを境に分割します。
$parts = preg_split('/<!--\s*paywall\s*-->/i', $page->content(), 2);
if (count($parts) === 2 && !pw_is_unlocked($page->key())) {
echo $parts[0]; // 無料部分だけ出力
include 'paywall-box.php'; // 決済UI
} else {
echo $page->content(); // 購入済み or マーカー無しは全文
}
重要なのは、未購入者へのレスポンスHTMLに有料部分を1バイトも含めないことです。CSSで隠すだけの実装(display:noneやぼかし)は、ソース表示で全文読めてしまいます。分割は必ずサーバーサイドで行います。
2. PayPal: ブラウザの自己申告を信用しない
決済UIは PayPal の Smart Buttons(JS SDK)です。ブラウザ側で決済が完了すると注文IDが得られるので、それをAPIに送って解錠します。
ここに罠があります。ブラウザから送られてくる「決済完了しました」は偽装できます。 注文IDの形式さえ合っていれば、決済せずに解錠APIを叩けてしまう。
対策は、サーバー側でPayPal APIに注文を照合することです。
1. client_id + secret で OAuth トークン取得 (POST /v1/oauth2/token)
2. GET /v2/checkout/orders/{注文ID}
3. status == COMPLETED を確認
4. 金額 (200 JPY) を確認
5. 購入者メールは PayPal の応答から取る(ブラウザの申告値は使わない)
この照合を入れると、偽の注文IDは「注文が見つかりません」で弾かれます。実際にテストで偽IDを投げて拒否されることを確認しました。
Sandbox / Live の罠
PayPal Developer ダッシュボードは、開くと必ず Sandbox(テスト環境)側が表示されます。ここで作ったアプリの Client ID / Secret は、本番APIでは invalid_client で認証に失敗します。
私はこれで2回、Sandboxの資格情報を掴まされました。見分け方は簡単で、取得した資格情報で両方のエンドポイントにOAuthを投げることです。
api-m.paypal.comで成功 → 本番(Live)の資格情報api-m.sandbox.paypal.comで成功 → テスト用。作り直し
ダッシュボード上部の Sandbox / Live 切り替えを「Live」にしてからアプリを作成する必要があります。切り替えた直後はアプリ一覧が空になるのが「Live側にいる」目印です。
3. トークン決済: オンチェーンをサーバーが直接見る
もう1つの決済レールは、Base チェーン上の ERC20 トークンです。読者は指定ウォレットへ 20,000 枚を送金し、送金元アドレスを入力して「支払い確認」を押します。
サーバーは、公開RPCに eth_getLogs を投げて Transfer イベントを直接確認します。
$topics = [
keccak256("Transfer(address,address,uint256)"), // イベントシグネチャ
pad32($送金元ウォレット), // from
pad32($受け取りウォレット), // to
];
// トークンコントラクト宛に、直近ブロック範囲で照会し、amountを合算
ポイントは3つあります。
- 決済プロバイダ不要: RPCはパブリックノードで足りる。手数料も審査もない
- from と to を両方 topic で絞る: 対象の送金だけがピンポイントで返る
- ブロック範囲は分割して照会: 公開RPCは広範囲の getLogs を拒むことがある。5万ブロックずつ数回に分けると安定する
送金額はイベントの data フィールド(32バイトのhex)から復元します。18桁のdecimalsを割るときは、閾値判定が目的なら float 精度で十分です。
4. 匿名のまま「買った人」を識別する
会員登録は要求しません。識別子は決済手段が勝手に連れてきます。
- PayPal購入者 → PayPal応答のメールアドレス
- トークン購入者 → 送金元ウォレットアドレス
台帳には 識別子 × 記事キー で1レコード記録し、閲覧の維持は識別子をHMAC署名したCookie(1年)で行います。
Cookie: KURAGEPAY = identifier|HMAC-SHA256(identifier, server_secret)
Cookieは「目印」であって、解錠判定は毎回サーバーの台帳と照合します。署名鍵はサーバー側だけが持つので、Cookieを自作しても識別子を偽装できません。
別端末での再解錠は、メール購入者には「メール + PayPal注文ID」の両方一致を要求します(メールだけで解錠できると、他人のメールを入れるだけで読めてしまうため)。ウォレット購入者は、オンチェーンに送金の事実が刻まれているので、再検証するだけです。
5. 障害時の設計: 決済は fail-closed、それ以外は素直に
エントリー可否のようなゲートは fail-open(障害時は通す)にすることが多いですが、決済の解錠は逆で、確認できなければ必ず閉じる(fail-closed)にします。PayPal APIに到達できない・RPCが応答しない場合は、解錠せずエラーを返して再試行してもらいます。
一方で、購入済みの読者を締め出さないよう、台帳・Cookie検証はシンプルに保ち、依存を減らしています。ペイウォールの実装そのものが落ちた場合は、テーマ側の分岐で「マーカーが無い記事は全文表示」に倒れるため、無料記事には一切影響しません。
まとめ
- 「途中まで無料」はサーバーサイド分割が絶対条件。CSSで隠すのは実装ではない
- PayPalはブラウザの完了報告を信用せず、サーバーからAPIで注文を照合する
- Sandbox/Liveの取り違えは両エンドポイントへのOAuth試行で機械的に判別できる
- ERC20の受領確認は eth_getLogs で自前実装できる。決済プロバイダは必須ではない
- 匿名決済でも、メール/ウォレットという「決済が連れてくる識別子」で購入者を追跡できる
外部SaaSに月額を払わなくても、有料記事は数百行で自作できます。決済の検証をサーバーに置くこと、これだけは省略しないでください。