Skip to content

外部システム連携ガイド

外部WEBアプリ等がサブスクライトのAPIを利用して、課金・サブスクリプション管理システムを構築する方法について説明します。

外部システム連携とは

サブスクライトの外部連携APIを使用することで、お持ちのWEBアプリやサービスに以下の機能を追加できます。

  • アプリのユーザーをサブスクライトに登録
  • PaymentLinkを使った安全なクレジットカード決済
  • ユーザーの契約状況に応じた有料機能の制御
  • サブスクリプションの解約・退会処理
  • アクティブ契約者の一括取得
  • Webhook通知: 購入・解約・決済失敗等のイベントをリアルタイムに受信(push型)

カード情報はサブスクライト(Stripe)側で処理されるため、アプリ側でカード情報を扱う必要はありません。

Cloudflare Workers / Pages Functionsとの連携

本連携スキームは、Cloudflare Workers・Pages Functions等のサーバーレス環境から無料枠で決済機能を利用することを想定した設計です。決済処理・サブスクリプション管理・自動課金・領収書送信は全てサブスクライト側で完結するため、外部アプリ側はAPI呼び出しとWebhook受信のみで決済対応アプリが構築できます。


連携フロー

以下のシーケンス図は、外部アプリとサブスクライト間の連携の全体像を示しています。

uml diagram


事前準備

外部連携を始める前に、以下の準備が必要です。

1. テナント設定の完了

テナント登録についてに従い、テナント設定を完了してください。

2. 商品設定の登録

商品を登録するに従い、販売する商品を設定してください。

  • 成功画面URL: 商品設定で成功画面URLを設定すると、PaymentLink決済完了後にそのURLにリダイレクトされます。外部アプリの画面に戻したい場合に設定してください。
  • 未設定の場合は、サブスクライトの標準完了画面が表示されます。

3. APIトークンの発行

API連携機能に従い、APIトークンを発行してください。このトークンを外部アプリに設定します。


認証方式

すべてのAPIリクエストには、HTTPヘッダーにBearerトークンを含める必要があります。

Authorization: Bearer {あなたのAPIトークン}

トークンはサブスクライト管理画面の「API設定」から発行できます。

APIトークンの取り扱い

APIトークンはパスワードと同様に安全に管理してください。ソースコードに直接記載せず、環境変数等で管理することを推奨します。


レート制限

サブスクライトのAPIは1分あたり60リクエストのレート制限を設けています。

仕様

項目
制限値 60リクエスト / 分
カウント単位 認証ユーザー(APIトークン)単位
対象 /api/* 全エンドポイント(外部連携API + テナントAPI の合算
方式 スライディングウィンドウ(過去60秒以内のリクエスト数で判定)
超過時のレスポンス HTTP 429 Too Many Requests
超過時の再試行情報 Retry-After ヘッダ(秒数)

よくある誤解

「1分」であり、1時間ではありません

  • 60 req/分 = 最大 3,600 req/時 = 最大 86,400 req/日
  • 一般的なサービスでの新規ユーザー登録・契約状況確認の頻度では、まず上限に達しません。

同一テナント内の合算に注意

APIトークンはテナント単位で1本です。以下のエンドポイントはすべて同じ60/分バケットを共有します。

  • POST /v1/external/users (ユーザー登録)
  • PUT /v1/external/users (ユーザー情報更新)
  • GET /v1/external/customer-status (契約状況確認)
  • POST /v1/external/subscriptions/cancel (商品解約)
  • POST /v1/external/users/withdraw (ユーザー退会)
  • GET /v1/external/active-subscribers (アクティブ契約者一覧)
  • GET /{site}/get_product_settings 等のテナントAPI

たとえば契約状況確認APIをプラン一覧表示のたびに呼ぶ設計にすると、他の処理(ユーザー登録など)の枠を消費してしまうため、キャッシュ + Webhook駆動の組み合わせを強く推奨します。

429を受け取ったときの推奨ハンドリング

Retry-After ヘッダに記載された秒数だけ待機してから再試行してください。以下はCloudflare Workers / Node.js での実装例です。

async function callSubscliteApiWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const res = await fetch(url, {
      ...options,
      headers: {
        'Authorization': `Bearer ${SUBSCLITE_API_TOKEN}`,
        ...options.headers,
      },
    });
    if (res.status !== 429) return res;

    const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
    await new Promise(r => setTimeout(r, retryAfter * 1000));
  }
  throw new Error('Rate limit: max retries exceeded');
}

PHPでの例:

function callSubscliteApi(string $url, array $options, int $maxRetries = 3)
{
    for ($i = 0; $i < $maxRetries; $i++) {
        $response = Http::withToken(env('SUBSCLITE_API_TOKEN'))->send(
            $options['method'] ?? 'GET',
            $url,
            $options
        );
        if ($response->status() !== 429) {
            return $response;
        }
        $retryAfter = (int) ($response->header('Retry-After') ?? 60);
        sleep($retryAfter);
    }
    throw new \RuntimeException('Rate limit: max retries exceeded');
}

バルク処理時の注意

既存顧客の一括インポート等で短時間に大量のAPIリクエストを送信する場合、以下のいずれかで流量を制御してください。

  1. スリープ挿入: 1リクエストごとに sleep(1) を入れる → 厳密に60/分以内に収まる
  2. キューによる分散: Cloudflare Queues / Amazon SQS 等で1秒1件ペースに均す
  3. バッチ分割: 1時間に3,000件ずつ等、日をまたいで処理する

レート制限を気にせず運用するコツ

  • 契約状況確認は Webhook + 自社キャッシュ で代替(subscription.created / subscription.cancelled 受信時にキャッシュ更新)
  • プラン価格表示は product_setting.updated Webhook でキャッシュ無効化
  • API呼び出しをユーザー登録・解約などのイベント駆動のみに絞れば、通常運用でレート制限に到達することはまずありません

将来の緩和

テナントの事業規模拡大により60/分では不足する場合、個別にレート制限の引き上げ(プランオプション)のご相談が可能です。お問い合わせください。


APIリファレンス

ベースURL

https://main.subsclite.com/api/v1/external

エンドポイント一覧

メソッド エンドポイント 用途
POST /v1/external/users ユーザー登録
PUT /v1/external/users ユーザー情報更新
GET /v1/external/customer-status 契約状況確認
POST /v1/external/subscriptions/cancel 商品解約(1商品)
POST /v1/external/users/withdraw ユーザー退会(全商品解約)
GET /v1/external/active-subscribers アクティブ契約者一覧取得

POST /v1/external/users — ユーザー登録

外部アプリのユーザーをサブスクライトに登録します。Stripe Customerも自動作成されます。

リクエストパラメータ

パラメータ 必須 説明
email 必須 string メールアドレス(テナント内で一意)
name 必須 string 氏名(最大255文字)
name_kana 任意 string フリガナ(最大255文字)
userid 任意 string ログインID(未指定時はメールアドレスから自動生成)
send_welcome_email 任意 boolean ウェルカムメールの送信(デフォルト: false)

レスポンス例(成功: 201)

{
  "success": true,
  "message": "User created successfully",
  "user_id": 123,
  "email": "user@example.com",
  "login_id": "user_at_example_com",
  "customer_id": "123456789",
  "stripe_customer_id": "cus_xxxxxxxxxxxxx"
}

レスポンス例(再登録: 200)

退会済みユーザーが同じメールアドレスで再登録された場合:

{
  "success": true,
  "message": "User reactivated successfully",
  "user_id": 123,
  "email": "user@example.com",
  "login_id": "user_at_example_com",
  "customer_id": "123456789",
  "reactivated": true
}

エラーレスポンス

HTTPコード 原因
422 バリデーションエラー(メール形式不正等)
409 同じメールアドレスのアクティブユーザーが既に存在
500 Stripe Customer作成失敗、サーバーエラー

PUT /v1/external/users — ユーザー情報更新

登録済みユーザーの情報を更新します。Stripe Customer情報も自動同期されます。

リクエストパラメータ

ユーザー検索用(いずれか1つ以上必須):

パラメータ 説明
email string 現在のメールアドレスで検索
customer_id integer 顧客番号で検索
userid string ログインIDで検索

更新内容(いずれか1つ以上必須):

パラメータ 説明
new_email string 新しいメールアドレス(テナント内で一意)
name string 新しい氏名
name_kana string 新しいフリガナ

レスポンス例(成功: 200)

{
  "success": true,
  "message": "User updated successfully",
  "user": {
    "id": 123,
    "email": "newemail@example.com",
    "name": "新しい名前",
    "name_kana": "アタラシイ ナマエ",
    "customer_id": "123456789",
    "login_id": "user_at_example_com",
    "stripe_customer_id": "cus_xxxxxxxxxxxxx"
  }
}

エラーレスポンス

HTTPコード 原因
422 バリデーションエラー、検索キー未指定、更新項目未指定
404 指定した条件のユーザーが見つからない
422 退会済みユーザーは更新不可
422 新しいメールアドレスが既に使用されている

GET /v1/external/customer-status — 契約状況確認

ユーザーの現在の契約状況を確認します。有料機能のアクセス制御に使用します。

リクエストパラメータ(クエリパラメータ、いずれか1つ以上必須)

パラメータ 説明
email string メールアドレスで検索
customer_id integer 顧客番号で検索
userid string ログインIDで検索

リクエスト例

GET /api/v1/external/customer-status?email=user@example.com

レスポンス例(成功: 200)

{
  "success": true,
  "email": "user@example.com",
  "user_id": 123,
  "user": {
    "email": "user@example.com",
    "name": "山田太郎",
    "customer_id": 123456789,
    "created_at": "2025-01-15T10:30:00Z"
  },
  "contracts": [
    {
      "contract_id": "premium_abc123def",
      "product_name": "プレミアムプラン",
      "product_setting_id": 5,
      "status": "active",
      "contract_status": 2,
      "payment_failed": false,
      "trial_ends_at": null,
      "started_at": "2025-01-15T10:30:00Z",
      "next_billing_date": "2025-02-15",
      "stripe_subscription_id": "sub_xxxxxxxxxxxxx"
    }
  ],
  "total_active_contracts": 1,
  "has_active_subscription": true
}

契約ステータスの値

contract_status status 説明
1 trialing 試用期間中
2 active 定額使用中
3 paused 休止中
7 paid 支払い済み(単発決済)
8 exempted 支払い免除

エラーレスポンス

HTTPコード 原因
422 検索キー未指定
404 ユーザーが見つからない

有料機能制御の実装例

has_active_subscriptiontrue の場合に有料機能を許可し、false の場合は購入案内を表示する実装が推奨されます。特定の商品の契約を確認したい場合は contracts 配列の product_setting_id で判定してください。


POST /v1/external/subscriptions/cancel — 商品解約

特定の商品の契約を解約します。アカウントは維持されます。

リクエストパラメータ

パラメータ 必須 説明
email 必須 string 対象ユーザーのメールアドレス
product_setting_id 必須 integer 解約する商品の商品設定ID

レスポンス例(成功: 200)

{
  "success": true,
  "message": "Subscription cancelled successfully",
  "contract_id": "premium_abc123def",
  "cancelled_at": "2025-03-20T14:30:00Z"
}

エラーレスポンス

HTTPコード 原因
422 バリデーションエラー
404 ユーザーが見つからない、またはアクティブな契約がない

POST /v1/external/users/withdraw — ユーザー退会

ユーザーを退会処理します。全ての契約が解約され、アカウントが無効化されます。

リクエストパラメータ

パラメータ 必須 説明
email 必須 string 退会させるユーザーのメールアドレス

レスポンス例(成功: 200)

{
  "success": true,
  "message": "User withdrawn successfully",
  "user_id": 123,
  "cancelled_subscriptions": 3,
  "withdrawn_at": "2025-03-20T14:30:00Z"
}

エラーレスポンス

HTTPコード 原因
422 バリデーションエラー
404 ユーザーが見つからない
409 既に退会済み

解約と退会の違い

  • 解約: 1つの商品の契約を終了します。アカウントは維持され、他の契約は影響を受けません。
  • 退会: 全ての商品が解約され、アカウントが無効化されます。退会済みユーザーは同じメールアドレスで再登録(再有効化)が可能です。

GET /v1/external/active-subscribers — アクティブ契約者一覧取得

指定した商品のアクティブな契約者一覧を取得します。バッチ処理や管理画面での利用を想定しています。

リクエストパラメータ(クエリパラメータ)

パラメータ 必須 説明
product_setting_ids 必須 string 商品設定IDのカンマ区切り(例: 5,7,9
include_payment_failed 任意 boolean 決済失敗者を含めるか(デフォルト: false)

リクエスト例

GET /api/v1/external/active-subscribers?product_setting_ids=5,7&include_payment_failed=true

レスポンス例(成功: 200)

{
  "success": true,
  "product_setting_ids": [5, 7],
  "subscribers": [
    {
      "email": "user1@example.com",
      "name": "田中一郎",
      "customer_id": 111111111,
      "has_payment_failed": false,
      "products": [
        {
          "product_setting_id": 5,
          "product_name": "プレミアムプラン",
          "contract_status": 2,
          "payment_failed": false
        }
      ]
    },
    {
      "email": "user2@example.com",
      "name": "鈴木二郎",
      "customer_id": 222222222,
      "has_payment_failed": true,
      "products": [
        {
          "product_setting_id": 7,
          "product_name": "スタンダードプラン",
          "contract_status": 2,
          "payment_failed": true
        }
      ]
    }
  ],
  "total_count": 2
}

エラーレスポンス

HTTPコード 原因
422 product_setting_ids未指定、形式不正
404 指定した商品設定IDがテナントに存在しない

外部アプリから利用者をサブスクライトの購入ページに誘導する際は、PaymentLinkを使用します。

https://main.subsclite.com/{URLパラメータ}/payment_link?product={商品名}&email={メールアドレス}
パラメータ 説明
URLパラメータ テナント設定で指定したURLパラメータ
product 商品設定の商品名(URLエンコード必要)
email 利用者のメールアドレス(事前入力される)

メールアドレスの事前入力

emailパラメータを指定すると、購入ページのメールアドレス欄が事前入力された状態で表示されます。 ユーザー登録API(POST /v1/external/users)で登録したメールアドレスを指定してください。

購入完了後のリダイレクト

商品設定で成功画面URLを設定している場合、購入完了後にそのURLにリダイレクトされます。 これにより、利用者を外部アプリの画面に戻すことができます。

  • 成功画面URLが設定されている場合: 指定したURLにリダイレクト
  • 成功画面URLが未設定の場合: サブスクライトの標準完了画面を表示

実装の流れ

Step 1: 事前準備

  1. サブスクライト管理画面でテナント設定・商品設定を完了
  2. 商品設定の成功画面URLに外部アプリのURLを設定
  3. API設定画面でAPIトークンを発行
  4. 発行したトークンを外部アプリの環境変数等に設定

Step 2: ユーザー登録の実装

外部アプリでユーザーが登録された際に、POST /v1/external/users でサブスクライトにもユーザーを作成します。レスポンスの customer_id を外部アプリ側で保存しておくと、以降の操作に便利です。

Step 3: 購入フローの実装

有料版の購入ボタンから、PaymentLink URLにリダイレクトします。email パラメータに利用者のメールアドレスを含めてください。

Step 4: 有料機能の制御

有料機能を使用する際に GET /v1/external/customer-status で契約状況を確認し、has_active_subscription の値に基づいて機能の許可/不許可を制御します。

Step 5: 解約・退会の実装

解約・退会の操作は、それぞれのAPIエンドポイントを呼び出して処理します。


補足事項

決済について

  • 定額決済の課金はStripeが自動的に実行します。外部アプリ側で決済処理を行う必要はありません。
  • 利用者には領収書メールがStripeから自動送信されます。

複数商品の契約

  • 1人のユーザーが複数の商品を契約することが可能です。
  • 追加購入の際も同様にPaymentLinkにリダイレクトしてください。

退会済みユーザーの再登録

  • 退会済みユーザーが同じメールアドレスで POST /v1/external/users を呼び出した場合、アカウントが再有効化されます。

Webhook通知(push型連携)

前述のAPIはいずれも pull型(外部アプリ→サブスクライト)でした。これに加えてサブスクライトは、ビジネスイベントが発生した際にテナント指定のURLへHTTP POSTで通知する push型Webhook をサポートします。

OpenAPI仕様書(Swagger)にも payload schema を掲載しています

各イベントのpayload構造はOpenAPI仕様書(subsclite_api.yaml)の definitions: セクションにも WebhookEvent_* という名前で登録されています。 Swagger UI / Stoplight 等のビューワで開くと「Models」パネルから各イベントの構造を機械可読な形式で参照できます。

event_type OpenAPI定義名
subscription.created WebhookEvent_SubscriptionCreated
subscription.updated WebhookEvent_SubscriptionUpdated
subscription.cancelled WebhookEvent_SubscriptionCancelled
payment.succeeded WebhookEvent_PaymentSucceeded
payment.failed WebhookEvent_PaymentFailed
trial.ending_soon WebhookEvent_TrialEndingSoon
user.withdrawn WebhookEvent_UserWithdrawn
product_setting.updated WebhookEvent_ProductSettingUpdated

本ガイドは署名検証ロジック・運用パターン・推奨実装まで含む実装ガイド、OpenAPIはコード生成・型定義生成向けの機械可読な構造定義、という使い分けを想定しています。

主な用途

  • 購入完了時に外部アプリ側で有料機能を即座にアンロック
  • 決済失敗をリアルタイムに検知し、ユーザーに再認証を促す
  • 解約・退会をトリガーに外部アプリ側のデータを更新
  • GET /v1/external/customer-status のポーリング頻度を大幅削減

Cloudflare Workers / Pages Functions等のサーバーレス環境で稼働するアプリに最適です。

対応イベント種別(全8種)

event_type 説明
subscription.created サブスクリプション作成(購入完了)
subscription.updated サブスクリプション状態変更(試用期間→定額使用等)
subscription.cancelled サブスクリプション解約
payment.succeeded 決済成功
payment.failed 決済失敗(3Dセキュア再認証要求含む)
trial.ending_soon 試用期間終了前通知
user.withdrawn ユーザー退会
product_setting.updated 商品設定(価格・販売停止等)変更

事前準備

1. エンドポイント登録

サブスクライト管理画面の /api_setting にアクセスし、「Webhook通知設定」カードで以下を登録します。

項目 説明
名前 テナント内で識別するラベル(例: "本番アプリWebhook")
URL 外部アプリのWebhook受信エンドポイント(本番環境はHTTPS必須)
購読イベント 受信したいイベントをチェックボックスで選択

登録完了時にSecret(HMAC署名検証用の共有鍵、64文字)が 1度だけ 表示されます。必ずこの時点で保管してください。再表示はできません。

Secretの取り扱い

Secretはパスワードと同様に安全に管理してください。ソースコードに直接記載せず、環境変数等で管理することを推奨します。紛失時は「Secret更新」ボタンでローテーションが可能です。

2. SSRF対策

サブスクライトはURL登録時にホスト名を解決し、プライベートIPアドレス(127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16 等)やlocalhost、予約IP範囲を拒否します。


HTTPリクエスト仕様

サブスクライトから外部アプリへ送信されるリクエストは以下の形式です。

POST /your-webhook-endpoint HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: Subsclite-Webhooks/1.0
X-Subsclite-Event: subscription.cancelled
X-Subsclite-Delivery: 8fbd1e3a-... (UUID、試行毎に一意)
X-Subsclite-Signature: t=1713283200,v1=7b9e... (HMAC-SHA256)
X-Subsclite-Retry-Count: 0

{
  "event_id": "b6c3a2f4-...",
  "event_type": "subscription.cancelled",
  "delivered_at": "2026-04-16T12:00:00+00:00",
  "site_setting_id": 42,
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_abc123",
    "product_setting_id": 7,
    "contract_status": 5,
    "cancelled_at": "2026-04-16T12:00:00+00:00",
    "source": "external_api"
  }
}

必須ヘッダ

ヘッダ 説明
X-Subsclite-Event イベント種別(event_typeと同値)
X-Subsclite-Delivery 配信試行のUUID。同じevent_idでも再送のたびに異なる
X-Subsclite-Signature t=<unix秒>,v1=<HMAC-SHA256 16進文字列> 形式の署名
X-Subsclite-Retry-Count リトライ回数(0=初回)

共通ペイロードフィールド

フィールド 説明
event_id UUID string ビジネスイベントの一意識別子。再送でも不変
event_type string subscription.created 等のイベント種別
delivered_at ISO 8601 送信時刻(UTC)
site_setting_id integer テナント識別子
data object イベント固有のデータ(下記)

署名検証

セキュリティのため、受信側で 必ず署名を検証 してください。検証は以下のステップで行います:

  1. X-Subsclite-Signature ヘッダを t=<timestamp>,v1=<signature> に分解
  2. 現在時刻と timestamp の差が5分以内であることを確認(リプレイ攻撃対策)
  3. <timestamp>.<リクエストボディ> をSecretでHMAC-SHA256計算し、v1 と一致するかタイミングセーフ比較

Node.js / Cloudflare Workers

// Cloudflare Workers / Pages Functions向け
async function verifyWebhookSignature(request, secret) {
  const header = request.headers.get('X-Subsclite-Signature');
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('='))
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;

  // 5分以上古いタイムスタンプは拒否
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) return false;

  const body = await request.text();
  const payload = `${timestamp}.${body}`;

  // Web Crypto APIでHMAC-SHA256を計算
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sigBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(payload)
  );
  const expected = [...new Uint8Array(sigBuffer)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // タイミングセーフ比較
  if (expected.length !== signature.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
  }
  return diff === 0;
}

// Pages Functions エンドポイント例
export async function onRequestPost(context) {
  const valid = await verifyWebhookSignature(context.request, context.env.SUBSCLITE_WEBHOOK_SECRET);
  if (!valid) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = await context.request.json();
  // event_id で重複排除(既に処理済みならスキップ)
  const alreadySeen = await context.env.PROCESSED_EVENTS.get(payload.event_id);
  if (alreadySeen) {
    return new Response('Already processed', { status: 200 });
  }

  // イベント種別毎の処理
  switch (payload.event_type) {
    case 'subscription.created':
      // プラン切替による新規作成の場合、auto_cancelled_contracts に旧プランが含まれる
      await updateUserPlan(payload.data.email, payload.data.product_setting_id);
      break;
    case 'subscription.cancelled':
      if (payload.data.source === 'exclusive_group_auto_cancel') {
        // プラン切替による自動解約 → 新プラン情報は replaced_by_* で取得
        await switchUserPlan(
          payload.data.email,
          payload.data.product_setting_id,           // 旧プラン
          payload.data.replaced_by_product_setting_id // 新プラン
        );
      } else {
        await lockPremiumFeatures(payload.data.email);
      }
      break;
    case 'user.withdrawn':
      await lockPremiumFeatures(payload.data.email);
      break;
    // ...
  }

  // 処理済みマーク(24時間保持)
  await context.env.PROCESSED_EVENTS.put(payload.event_id, '1', { expirationTtl: 86400 });
  return new Response('OK', { status: 200 });
}

PHP

function verifyWebhookSignature(string $header, string $body, string $secret): bool
{
    if (!preg_match('/^t=(\d+),v1=([a-f0-9]+)$/', $header, $m)) {
        return false;
    }
    [$_, $timestamp, $signature] = $m;

    // 5分以上古いタイムスタンプは拒否
    if (abs(time() - (int)$timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
    return hash_equals($expected, $signature);
}

Python

import hashlib
import hmac
import time

def verify_webhook_signature(header: str, body: bytes, secret: str) -> bool:
    parts = dict(p.split('=') for p in header.split(','))
    timestamp = int(parts['t'])
    signature = parts['v1']

    # 5分以上古いタイムスタンプは拒否
    if abs(time.time() - timestamp) > 300:
        return False

    payload = f"{timestamp}.".encode() + body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

イベント毎のペイロード例

subscription.created

{
  "event_id": "a1b2c3d4-...",
  "event_type": "subscription.created",
  "delivered_at": "2026-04-16T12:00:00+00:00",
  "site_setting_id": 42,
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_xyz789",
    "product_setting_id": 7,
    "contract_status": 1,
    "stripe_subscription_id": "sub_xxxxxxxxxxxxx",
    "stripe_status": "trialing",
    "trial_ends_at": "2026-04-30T12:00:00+00:00",
    "started_at": "2026-04-16T12:00:00+00:00",
    "exclusive_group_key": "paid_main",
    "auto_cancelled_contracts": [
      {
        "product_setting_id": 5,
        "cancelled_at": "2026-04-16T12:00:00+00:00"
      }
    ]
  }
}

排他グループによるプラン切替イベント

商品設定で「排他グループキー」が設定されている商品の購入時、同じキーを持つ既存契約が自動解約されます。この場合:

  • exclusive_group_key: 対象商品の排他グループキー(未設定商品では null
  • auto_cancelled_contracts: 自動解約された既存契約の一覧(解約がなければ空配列 []
  • 同時に subscription.cancelled Webhook も発火し、sourceexclusive_group_auto_cancel になります(後述)

外部アプリは subscription.createdsubscription.cancelled を関連付けて「プラン切替」として扱うことができます。

subscription.updated

{
  "event_type": "subscription.updated",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_xyz789",
    "product_setting_id": 7,
    "stripe_subscription_id": "sub_xxxxxxxxxxxxx",
    "previous_status": "trialing",
    "current_status": "active",
    "contract_status": 2,
    "updated_at": "2026-04-30T12:00:00+00:00"
  }
}

subscription.cancelled

{
  "event_type": "subscription.cancelled",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_xyz789",
    "product_setting_id": 7,
    "contract_status": 5,
    "cancelled_at": "2026-05-01T12:00:00+00:00",
    "source": "external_api"
  }
}

source フィールドで解約経路を識別できます:

source 意味
external_api 外部連携API(/subscriptions/cancel)経由の解約
stripe_webhook Stripe側で発生した解約イベント経由
option_product_ui サブスクライトUIからのオプション商品解約
option_product_ui_admin_proxy 特権管理者による代理解約
exclusive_group_auto_cancel 排他グループ設定による自動解約(プラン切替)
排他グループ自動解約時の追加フィールド

sourceexclusive_group_auto_cancel の場合、以下の追加フィールドが含まれます:

{
  "event_type": "subscription.cancelled",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_plus_abc123",
    "product_setting_id": 5,
    "contract_status": 5,
    "penalty_charged": false,
    "cancelled_at": "2026-04-16T12:00:00+00:00",
    "source": "exclusive_group_auto_cancel",
    "exclusive_group_key": "paid_main",
    "replaced_by_contract_id": "premium_pro_xyz789",
    "replaced_by_product_setting_id": 7
  }
}
フィールド 説明
penalty_charged boolean 解約費が請求されたか(商品設定の「排他解約時は解約費免除」次第)
exclusive_group_key string 排他グループキー
replaced_by_contract_id string 新しく購入された後継プランの契約ID
replaced_by_product_setting_id integer 新しく購入された後継プランのproduct_setting_id

ユースケース例(プラン切替): 外部アプリ側で「Plusプラン契約中ユーザーがProプランを購入 → Plus契約は自動解約、Pro契約が有効になる」という一連のイベントを受信できます。外部アプリは自社のユーザー状態を replaced_by_product_setting_id で示された新プランに移行できます。

payment.succeeded

{
  "event_type": "payment.succeeded",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "invoice_id": "in_xxxxxxxxxxxxx",
    "amount_paid": 1980,
    "currency": "jpy",
    "subscription_id": "sub_xxxxxxxxxxxxx",
    "paid_at": "2026-05-16T12:00:00+00:00"
  }
}

payment.failed

{
  "event_type": "payment.failed",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_xyz789",
    "product_setting_id": 7,
    "payment_failed_id": 12345,
    "invoice_id": "in_xxxxxxxxxxxxx",
    "failure_reason": "authentication_required",
    "requires_action": true,
    "retry_url": "https://main.subsclite.com/payment-auth/8a7f...",
    "amount": 1980
  }
}

requires_actiontrue の場合、retry_url にユーザーを誘導することで3Dセキュア再認証が実行できます。

trial.ending_soon

{
  "event_type": "trial.ending_soon",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "contract_id": "premium_xyz789",
    "product_setting_id": 7,
    "stripe_subscription_id": "sub_xxxxxxxxxxxxx",
    "trial_ends_at": "2026-05-03T00:00:00+00:00",
    "days_remaining": 3,
    "source": "stripe_webhook"
  }
}

user.withdrawn

{
  "event_type": "user.withdrawn",
  "data": {
    "customer_id": 123456789,
    "email": "user@example.com",
    "user_id": 456,
    "cancelled_subscriptions": 2,
    "withdrawn_at": "2026-06-01T12:00:00+00:00",
    "source": "external_api"
  }
}

product_setting.updated

テナントの商品設定(プラン)で価格・販売停止・試用期間・商品説明等が変更された際に発火します。 外部アプリ側でプラン一覧や価格表示をキャッシュしている場合、このイベント受信をトリガーにキャッシュを無効化し、 次回アクセス時に GET /v1/external 系または GET /{site}/get_product_settings で最新情報を取得することで、 プラン一覧画面を表示するたびのAPI呼び出しが不要 になります。

{
  "event_type": "product_setting.updated",
  "data": {
    "product_setting_id": 7,
    "name": "プレミアムプラン",
    "payment_type": 1,
    "single_price": null,
    "cycle_price": 5500,
    "cycle_interval_value": 1,
    "cycle_interval_unit": "month",
    "initial_price": null,
    "cancel_price": null,
    "suspension_price": null,
    "trial_days": 7,
    "tax_rate": 10,
    "sale_stop": 0,
    "payment_link_url": "https://buy.stripe.com/test_xxxxx",
    "payment_link_active": 1,
    "updated_at": "2026-04-21T12:00:00+00:00",
    "changed_fields": {
      "price": true,
      "sale_stop": false,
      "meta": false
    },
    "source": "admin_product_setting_update"
  }
}

changed_fields で何が変わったか判別可能です:

フィールド 意味
price cycle_price / single_price / initial_price / cancel_price / suspension_price / tax_rate のいずれかが変更
sale_stop 販売停止フラグが変更
meta name / description / trial_days / cycle_interval_value / cycle_interval_unit のいずれかが変更

連続更新時の順序

短時間に複数回更新された場合、Webhookは複数件配信されます。配信順序は保証されません。 外部アプリ側では updated_at タイムスタンプで比較し、後勝ち (last-write-wins) で処理することを推奨します。


プラン価格の同期戦略(推奨実装パターン)

外部アプリのプラン紹介画面に「月額5,500円」のように価格を表示する場合、 サブスクライト側での価格変更に自動追従させる実装は以下の3層構成を推奨します。

1. 初回取得(Pull)

顧客アカウント作成やデプロイ時に GET /{site}/get_product_settings で全プラン情報を取得し、 自社アプリのキャッシュ(Redis / Cloudflare KV / DB 等)に保存します。

2. 変更時の即時反映(Push)

product_setting.updated Webhookを購読し、受信時にキャッシュを無効化(または該当 product_setting_id のレコードを更新)します。 Webhookのペイロードには更新後の全主要フィールドが含まれているため、API再呼び出し不要でキャッシュ更新が完結 します。

3. Fail-safe(Version Poll)

Webhookがネットワーク障害等で恒久的に失われた場合の保険として、 1日1回程度 GET /{site}/get_product_settings_version を呼び出し、 version ハッシュが変わっていれば GET /{site}/get_product_settings でフル再取得します。 このエンドポイントは軽量(約200バイト)かつサーバ側で60秒キャッシュされるため、レート制限への影響は最小です。

同期戦略の効果

戦略 レート消費 鮮度 備考
画面表示のたびにAPI呼び出し 極大(60/分ですぐ枯渇) 最新 非推奨。CDN前段があってもBearer認証付きリクエストはキャッシュされない
1時間ごとにバッチでAPI呼び出し 小(24回/日) 最大1時間遅延 Webhookなしでも運用可能
本推奨(Push + Fail-safe) 最小(変更時のみ + 1回/日) ほぼリアルタイム Webhook受信は1テナント1回/変更
// Cloudflare Workers での実装例
export async function onRequestPost(context) {
  const payload = await context.request.json();

  if (payload.event_type === 'product_setting.updated') {
    // キャッシュを無効化(次アクセス時に再取得)
    await context.env.PLAN_CACHE.delete(`plan:${payload.data.product_setting_id}`);

    // またはペイロードから直接キャッシュ更新
    await context.env.PLAN_CACHE.put(
      `plan:${payload.data.product_setting_id}`,
      JSON.stringify(payload.data),
      { expirationTtl: 86400 * 7 }  // Webhook消失時用の保険で7日
    );
  }

  return new Response('OK', { status: 200 });
}

リトライポリシー

外部アプリが2xx以外のレスポンスを返した場合(またはタイムアウト・接続失敗)、以下のスケジュールで自動的に再送されます。

試行 累積経過時間 バックオフ
1回目 0秒 -
2回目 30秒後 30秒
3回目 約2.5分後 2分
4回目 約12.5分後 10分
5回目 約1時間12分後 1時間
6回目 約7時間12分後 6時間
abandoned (最終試行後に諦める) -

最大6回の試行、合計約7時間 のリトライ期間を設けています。6回目も失敗した場合はdeliveryレコードがabandoned状態となり、管理画面 /api_setting → 対象endpointの「履歴」から 手動で再送 が可能です。

タイムアウト設定

  • リクエストタイムアウト: 10秒

外部アプリは10秒以内にレスポンスを返してください。それ以上の処理が必要な場合は、受信直後に2xxを返し、実際の処理は非同期で行うパターンを推奨します。


冪等性の確保(重要)

ネットワーク障害等で同じイベントが複数回配信される可能性があります。受信側で 必ず event_id による重複排除 を実装してください。

// 冪等性の実装例(Cloudflare KV使用)
const seen = await env.PROCESSED_EVENTS.get(payload.event_id);
if (seen) return new Response('Already processed', { status: 200 });
// ... 処理 ...
await env.PROCESSED_EVENTS.put(payload.event_id, '1', { expirationTtl: 86400 });
フィールド 変わらないもの 再送ごとに変わるもの
event_id ✅ ビジネスイベント単位で一意
X-Subsclite-Delivery ✅ 試行ごとに一意

event_id を冪等キーとして保存すれば、同一イベントの再送をスキップできます。


管理画面での運用

/api_setting 画面

  • 登録済みエンドポイントの一覧表示
  • Secret更新(旧Secretは24時間のgrace期間内は受信側で受け入れ可能な設計)
  • エンドポイントの有効/無効切替
  • エンドポイント削除(配信履歴も同時削除)

配信履歴画面

各エンドポイントの「履歴」ボタンから直近100件の配信状況を確認できます:

項目 表示内容
作成日時 deliveryレコード生成時刻
イベント event_type
状態 pending / in_progress / delivered / abandoned
試行回数 現在の試行数 / max(6)
HTTP 最終レスポンスのHTTPステータスコード
次回試行 バックオフ中の次回試行予定時刻
配信完了 2xx受信時刻

失敗・abandoned状態の配信はワンクリックで再送キューに戻せます。


実装チェックリスト

外部アプリ側での実装前に以下を確認してください。

  • [ ] /api_setting で本番環境・開発環境別にエンドポイントを登録
  • [ ] 登録時に表示されたSecretを環境変数に保存(再表示不可)
  • [ ] 署名検証ロジックを実装(X-Subsclite-Signature のHMAC-SHA256検証)
  • [ ] タイムスタンプの5分スキュー検証を追加
  • [ ] event_id をキーにした重複排除を実装(KV/Redis/DB)
  • [ ] 10秒以内に2xxを返すエンドポイント設計
  • [ ] 失敗時のログ記録(X-Subsclite-Delivery も記録すると追跡しやすい)
  • [ ] 本番デプロイ前にwebhook.site等でペイロード構造を確認