外部システム連携ガイド¶
外部WEBアプリ等がサブスクライトのAPIを利用して、課金・サブスクリプション管理システムを構築する方法について説明します。
外部システム連携とは¶
サブスクライトの外部連携APIを使用することで、お持ちのWEBアプリやサービスに以下の機能を追加できます。
- アプリのユーザーをサブスクライトに登録
- PaymentLinkを使った安全なクレジットカード決済
- ユーザーの契約状況に応じた有料機能の制御
- サブスクリプションの解約・退会処理
- アクティブ契約者の一括取得
- Webhook通知: 購入・解約・決済失敗等のイベントをリアルタイムに受信(push型)
カード情報はサブスクライト(Stripe)側で処理されるため、アプリ側でカード情報を扱う必要はありません。
Cloudflare Workers / Pages Functionsとの連携
本連携スキームは、Cloudflare Workers・Pages Functions等のサーバーレス環境から無料枠で決済機能を利用することを想定した設計です。決済処理・サブスクリプション管理・自動課金・領収書送信は全てサブスクライト側で完結するため、外部アプリ側はAPI呼び出しとWebhook受信のみで決済対応アプリが構築できます。
連携フロー¶
以下のシーケンス図は、外部アプリとサブスクライト間の連携の全体像を示しています。
事前準備¶
外部連携を始める前に、以下の準備が必要です。
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リクエストごとに
sleep(1)を入れる → 厳密に60/分以内に収まる - キューによる分散: Cloudflare Queues / Amazon SQS 等で1秒1件ペースに均す
- バッチ分割: 1時間に3,000件ずつ等、日をまたいで処理する
レート制限を気にせず運用するコツ
- 契約状況確認は Webhook + 自社キャッシュ で代替(
subscription.created/subscription.cancelled受信時にキャッシュ更新) - プラン価格表示は
product_setting.updatedWebhook でキャッシュ無効化 - 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も自動作成されます。
リクエストパラメータ¶
| パラメータ | 必須 | 型 | 説明 |
|---|---|---|---|
| 必須 | 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つ以上必須):
| パラメータ | 型 | 説明 |
|---|---|---|
| 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つ以上必須)¶
| パラメータ | 型 | 説明 |
|---|---|---|
| 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_subscription が true の場合に有料機能を許可し、false の場合は購入案内を表示する実装が推奨されます。特定の商品の契約を確認したい場合は contracts 配列の product_setting_id で判定してください。
POST /v1/external/subscriptions/cancel — 商品解約¶
特定の商品の契約を解約します。アカウントは維持されます。
リクエストパラメータ¶
| パラメータ | 必須 | 型 | 説明 |
|---|---|---|---|
| 必須 | 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 — ユーザー退会¶
ユーザーを退会処理します。全ての契約が解約され、アカウントが無効化されます。
リクエストパラメータ¶
| パラメータ | 必須 | 型 | 説明 |
|---|---|---|---|
| 必須 | 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による購入フロー¶
外部アプリから利用者をサブスクライトの購入ページに誘導する際は、PaymentLinkを使用します。
PaymentLink URLの構成¶
https://main.subsclite.com/{URLパラメータ}/payment_link?product={商品名}&email={メールアドレス}
| パラメータ | 説明 |
|---|---|
| URLパラメータ | テナント設定で指定したURLパラメータ |
| product | 商品設定の商品名(URLエンコード必要) |
| 利用者のメールアドレス(事前入力される) |
メールアドレスの事前入力¶
emailパラメータを指定すると、購入ページのメールアドレス欄が事前入力された状態で表示されます。
ユーザー登録API(POST /v1/external/users)で登録したメールアドレスを指定してください。
購入完了後のリダイレクト¶
商品設定で成功画面URLを設定している場合、購入完了後にそのURLにリダイレクトされます。 これにより、利用者を外部アプリの画面に戻すことができます。
- 成功画面URLが設定されている場合: 指定したURLにリダイレクト
- 成功画面URLが未設定の場合: サブスクライトの標準完了画面を表示
実装の流れ¶
Step 1: 事前準備¶
- サブスクライト管理画面でテナント設定・商品設定を完了
- 商品設定の成功画面URLに外部アプリのURLを設定
- API設定画面でAPIトークンを発行
- 発行したトークンを外部アプリの環境変数等に設定
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 | イベント固有のデータ(下記) |
署名検証¶
セキュリティのため、受信側で 必ず署名を検証 してください。検証は以下のステップで行います:
X-Subsclite-Signatureヘッダをt=<timestamp>,v1=<signature>に分解- 現在時刻と
timestampの差が5分以内であることを確認(リプレイ攻撃対策) <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.cancelledWebhook も発火し、sourceがexclusive_group_auto_cancelになります(後述)
外部アプリは subscription.created と subscription.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 |
排他グループ設定による自動解約(プラン切替) |
排他グループ自動解約時の追加フィールド¶
source が exclusive_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_action が true の場合、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等でペイロード構造を確認