はじめに
決済サービスStripeではWebhooksの設定をすることで決済やアカウント作成、カード情報登録など各種操作に伴うイベントを別サービスに通知することができます。
この記事ではStripe Webhooksの通知先(エンドポイント)をFirebaseのCloud Functionsを使って作成する手法を説明します。
基本実装
基本的な実装はStripeからPOST送信されるhttpsエンドポイントをCloud Functionsで用意するだけで動作します。
参考:HTTP リクエスト経由で関数を呼び出す(Firebase公式)
https://firebase.google.com/docs/functions/http-events
まずはFirebase CLIを使い通常の方法でFirebaseプロジェクトにCloud Functionsを導入してください。
任意の場所にfunctions
ディレクトリと関数のJSファイル(index.js等)が存在する状態を前提として、以下のように関数を追加します。
const functions = require('firebase-functions');
exports.stripe_webhook = functions.https.onRequest((request, response) => {
const event = request.body;
switch (event.type) { // イベントのタイプに応じて処理を行う
case 'payment_intent.succeeded': { // 例)PaymentIntentによる決済成功時
const paymentIntent = event.data.object; // PaymentIntentのインスタンスを取得
console.log(paymentIntent);
response.json({ received: true }); // ステータス200でレスポンスを返却
break;
}
case 'payment_method.attached': { // 例)PaymentMethodがカスタマーに紐づけられた時
const paymentMethod = event.data.object; // PaymentMethodのインスタンスを取得
console.log(paymentMethod);
response.json({ received: true }); // ステータス200でレスポンスを返却
break;
}
default: { // 想定していないイベントが通知された場合
return response.status(400).end(); // ステータス400でレスポンスを返却
}
}
});
関数をデプロイ後、Stripeのダッシュボードの左メニュー内「開発者」「Webhook」ページから「エンドポイントを追加」を選択し、エンドポイントURLおよび送信するイベントを入力します。
エンドポイントURLに上記の関数のURLを指定しますが、Cloud Functions for Firebaseでは
https.onRequest
で記述しデプロイされた関数は以下のような形式のURLから呼び出すことになります。
https://<リージョン名>-<プロジェクトID>.cloudfunctions.net/<関数名>
関数のデプロイされるリージョンは関数ごとに設定できますが、デフォルトではus-central1
になるようです。上記のコードでは設定していないので、エンドポイントのURLは以下のようになるはずです。
プロジェクトIDは各自のものを入力してください。
https://us-central1-<プロジェクトID>.cloudfunctions.net/stripe_webhook

送信イベントの種類は任意ですが、ひとまず「すべてのイベントを受信」をクリックすることで、通知することが可能なイベントがすべて指定したエンドポイントに送信されることになります。
ダッシュボード上でエンドポイントの追加が完了したら、詳細ページから「テストのWebhookを送信」ボタンを押して先ほどの関数内で対応するようにしたpayment_intent.succeeded
やpayment_method.attached
を選択し送信してみます。あるいは、実際にAPIを使って決済処理を行ってみます。私はExpoアプリで実装した決済フローがあるのでそちらで確認しました。
ダッシュボードからイベントを指定しテスト送信してみるとこのような感じでCloud Functionsでの受信が成功しました。
FirebaseのコンソールからFunctionsのログを見ると、PaymentMethodインスタンスの内容が取得できているのがわかります(関数のconsole.log
部分による)。
一方イベントを先ほどの関数で処理していないものに変えると、400を返しているのでエラーになります。
Firestoreと連携してみる
あとは受信したイベントを使って何をしてもいいわけですが、ここではひとまずそのままFirestoreに保存することを考えてみます。
イベントを受信したらstripe_events
コレクションにイベントの内容をドキュメントとして追加します。
一つのイベントがエンドポイントに送信される回数は一回とは保証されていないようなので(これはFirestoreに限らずすべての処理に共通するため注意が必要です)、
冪等性を保証するために、以下のようにドキュメントIDはイベントのIDを使うことにします。
また、created
プロパティにイベント発生時間(秒)が入っているので、Firestoreで扱いやすくするためTimestamp型でcreatedAt
として登録しておくとします。
const functions = require('firebase-functions');
const firebase = require('firebase-admin');
firebase.initializeApp();
const db = firebase.firestore();
exports.stripe_webhook = functions.https.onRequest((request, response) => {
const event = request.body;
// stripe_eventsコレクションにドキュメントを追加
db.collection('/stripe_events').doc(event.id).set(
Object.assign({
createdAt: firebase.firestore.Timestamp.fromMillis(event.created * 1000)
}, event)
);
// イベントのタイプに応じて処理を行う
response.json({ received: true }); // 何かしらレスポンスは必須
});
デプロイし、何かしら決済を発生させてFirebaseコンソールで確認すると、イベントの内容がドキュメントとして保存されているのが確認できました。
イベント送信に関する注意事項
上述した冪等性の件を含めて、エンドポイントの実装に際して以下のような注意点があります。
- 一つのイベントが複数回送信される可能性がある(関数の冪等性に注意)
- エンドポイントからのレスポンスはなるべく即時行う(Webhooksからのリクエストへの対応とそれに伴う独自処理は別物と考える)
- イベント発生の順番とエンドポイントへの送信の順番は違う可能性がある
- 単純にhttpsリクエストが指定されたURLに送られるだけなので、エンドポイント側ではイベントがStripeから送信されたものかどうかを適切に確かめる
- エンドポイントの負担にならないよう、必要なイベントのみ送信されるように設定する
そのほか、詳しくはこちらを参照してください。
Best practices for using webhooks
https://stripe.com/docs/webhooks/best-practices
署名チェックを追加
エンドポイントに送信されたイベントが適切にStripeから送信されたものかどうかをチェックする処理を追加してみます。
エンドポイントへの各リクエストにはStripe-Signature
という署名のようなヘッダーが付与されていて、これを使ってイベントがStripeから送信されたものかどうかチェックし、セキュリティを高めることができます。
この署名のチェックにはエンドポイントごとに生成されるwhsec_
から始まる署名シークレットとNode.jsのstripe
パッケージおよびAPIシークレットキー(sk_...
)を使用します。
署名シークレットはStripeダッシュボードの各エンドポイントページから取得し、
APIシークレットキーはメニューの「開発者」「APIキー」ページから取得します。
Cloud Functionsのディレクトリでstripe
パッケージをインストールしておきます。
$ cd functions
$ npm install --save stripe
エンドポイントの関数では、リクエストボディ、前述の署名ヘッダー、署名シークレットを使用してEventオブジェクトを初期化します。この処理を踏むことで、適切な署名ヘッダーが存在していないリクエストを無効化することができます。
const functions = require('firebase-functions');
const stripe = require('stripe')('sk_test_...'); // APIシークレットキーを指定
const stripeWebhookEndpointSecret = 'whsec_...'; // 署名シークレットを指定
exports.stripe_webhook = functions.https.onRequest((request, response) => {
const sig = request.headers['stripe-signature'];
let event;
try {
// ボディのrawデータ、署名ヘッダー、署名シークレットを指定しイベントを初期化
event = stripe.webhooks.constructEvent(request.rawBody, sig, stripeWebhookEndpointSecret);
} catch (err) { // 不正なリクエストの場合
return response.status(400).send(`Webhook Error: ${err.message}`);
}
// イベントのタイプに応じて処理を行う
response.json({ received: true }); // 何かしらレスポンス
});
IP制限を追加
署名チェックに加えて、イベント送信元のIPアドレスによる制限を追加してみます。
StripeからのWebhooksイベントの送信は下記のIPアドレスからのみ行われるので、これ以外のIPアドレスからのリクエストを拒絶すればよいわけです。
3.18.12.63
3.130.192.231
13.235.14.237
13.235.122.149
35.154.171.200
52.15.183.38
54.187.174.169
54.187.205.235
54.187.216.72
54.241.31.99
54.241.31.102
54.241.34.107
引用元:https://stripe.com/docs/ips#webhook-ip-addresses
関数にIP制限の処理を追加します。
Cloud Functions for FirebaseでIP制限を行う方法については、akagireさんの下記の記事を参考にしました。
参考:Firebase Hosting の一部だけ IP でアクセス制限する
https://qiita.com/akagire/items/d1938c9246c074e7a9bd#ip-%E5%88%B6%E9%99%90%E3%81%99%E3%82%8B-cloud-functions-%E3%82%92%E7%94%A8%E6%84%8F
Node.jsサーバーから手軽にリクエスト元のIPアドレスを取得するパッケージを導入し、
$ npm install --save request-ip
const functions = require('firebase-functions');
const stripe = require('stripe')('sk_test_...'); // APIシークレットキーを指定
const requestIp = require('request-ip');
const stripeWebhookEndpointSecret = 'whsec_...';
const stripeWebhookIps = [
'3.18.12.63',
'3.130.192.231',
'13.235.14.237',
'13.235.122.149',
'35.154.171.200',
'52.15.183.38',
'54.187.174.169',
'54.187.205.235',
'54.187.216.72',
'54.241.31.99',
'54.241.31.102',
'54.241.34.107'
];
exports.stripe_webhook = functions.https.onRequest((request, response) => {
const clientIp = requestIp.getClientIp(request);
if (stripeWebhookIps.indexOf(clientIp) === -1) {
response.status(403).send('Webhook Error: Invalid IP Address');
}
const sig = request.headers['stripe-signature'];
let event;
try {
// ボディのrawデータ、署名ヘッダー、署名シークレットを指定しイベントを初期化
event = stripe.webhooks.constructEvent(request.rawBody, sig, stripeWebhookEndpointSecret);
} catch (err) { // 不正なリクエストの場合
return response.status(400).send(`Webhook Error: ${err.message}`);
}
// イベントのタイプに応じて処理を行う
response.json({ received: true }); // 何かしらレスポンス
});
このように、リストにないIPアドレスからのリクエストには403を返すようにしました。
デプロイ時にIPアドレスリストを取得する
このIPアドレスのリストは上記のようにハードコーディングせず、定数として別にファイル化しておいてもいいですが、テキストファイルおよびJSONファイルで公開されているので、これを関数のデプロイ前にダウンロードしておくというようなことも可能です。
Firebaseプロジェクトのディレクトリにfirebase.json
があると思いますが、functions.predeploy
にNode.jsスクリプトを追加するとデプロイ前に実行してくれます。
{
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"curl https://stripe.com/files/ips/ips_webhooks.json --output functions/data/stripe_webhooks_ips.json"
]
}
}
上記のように、curl
コマンドでJSONをダウンロードして指定のディレクトリに保存するようにしてみます。/functions/data
ディレクトリはあらかじめ作成しておいてください。
関数側からは、以下のようにJSONをそのままオブジェクトとしてrequireすることができます。
// const stripeWebhookIps = [
// '3.18.12.63',
// '3.130.192.231',
// '13.235.14.237',
// '13.235.122.149',
// '35.154.171.200',
// '52.15.183.38',
// '54.187.174.169',
// '54.187.205.235',
// '54.187.216.72',
// '54.241.31.99',
// '54.241.31.102',
// '54.241.34.107'
// ];
// ダウンロードしたJSONファイルをrequireして使用する
const stripeWebhookIps = require('./data/stripe_webhooks_ips.json').WEBHOOKS;
デプロイ時、設定したNode.jsスクリプトが以下のように実行されたら成功です。
$ firebase deploy --only functions
=== Deploying to 'test-project'...
i deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint
> functions@ lint /Users/.../functions
> eslint .
Running command: curl https://stripe.com/files/ips/ips_webhooks.json --output functions/data/ips_stripe_webhooks.json
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 246 100 246 0 0 497 0 --:--:-- --:--:-- --:--:-- 496
決済を発生させてみたりするとイベントが受信できているのがわかりますが、試しにPostman等でエンドポイント関数に向けてリクエストするとIPアドレスによる制限ができているのが確認できます。

エンドポイントとイベントタイプの管理
エンドポイントに送信されるイベントのタイプは必要なもののみ指定することが望ましいですが、サービス側(関数側)で必要なイベントとダッシュボードでの設定を合わせるのは少し面倒な気もします。
エンドポイントの作成や設定はダッシュボードからだけでなくREST APIやNode.jsパッケージによって動的に行うことができるようなので、最後にこれを試してみます。
実装の概要
以下のような感じで。
- エンドポイント(関数)のリージョン名、関数名、受信するイベントタイプ一覧等をCloud Functions側の定数として管理する。
定数の管理方法はFirebaseの環境変数や.env等でもよいですが、ここではわかりやすいようにJSファイルにします。 - 関数のデプロイ時(
predeploy
)にエンドポイントの作成・更新を動的に行う。 - Cloud Functions側およびエンドポイントの作成・更新処理において、共有された1.の定数を使用する。
1. 定数ファイルを作成
関数をデプロイするリージョン名、関数名、ついでにシークレットキーを定数化。
exports.STRIPE_WEBHOOK_REGION = 'asia-northeast1';
exports.STRIPE_WEBHOOK_PATH_NAME = 'stripe_webhook';
exports.STRIPE_SECRET_KEY = 'sk_test_...';
関数側で使用するイベントを定数化。同時にtypo防止になります。
exports.PAYMENT_INTENT_SUCCEEDED = 'payment_intent.succeeded';
exports.PAYMENT_METHOD_ATTACHED = 'payment_method.attached';
2. エンドポイントの作成・更新処理
Node.jsでエンドポイントを作成する処理を実装します。使用しているモジュールは適宜インストールしてください。
const fs = require('fs');
const path = require('path');
const Config = require('../constants/Config');
const stripe = require('stripe')(Config.STRIPE_SECRET_KEY);
const arrayEquals = require('array-equal');
// コマンドからプロジェクトIDを取得する
const commandLineArgs = require('command-line-args');
const optionDefinitions = [
{
name: 'project',
alias: 'p',
type: String
}
];
const options = commandLineArgs(optionDefinitions);
// イベントタイプをrequire
const StripeEvents = require('../constants/StripeEvents');
(async () => {
if (options.project) {
// リージョン名、プロジェクト名、関数名からURLを作成
const url = `https://${Config.STRIPE_WEBHOOK_REGION}-${options.project}.cloudfunctions.net/${Config.STRIPE_WEBHOOK_PATH_NAME}`;
// 既存のエンドポイントを取得(max100件)
const { data: webhookEndpointList } = await stripe.webhookEndpoints.list({ limit: 100 });
// 同じURLのエンドポイントを取得
const currentWebhookEndpoint = webhookEndpointList.find((endpoint) => (endpoint.url === url));
// イベントタイプを配列にする
const enabledEvents = Object.keys(StripeEvents).map((eventKey) => (StripeEvents[eventKey]));
if (currentWebhookEndpoint) { // 同じURLのエンドポイントがある場合
if (arrayEquals(currentWebhookEndpoint.enabled_events, enabledEvents)) { // イベントタイプに変更がない場合
console.log('The Stripe Webhook endpoint of current Firebase project already exists.');
} else { // イベントタイプに変更がある場合、更新する
await stripe.webhookEndpoints.update(
currentWebhookEndpoint.id,
{ enabled_events: enabledEvents }
);
console.log('Current Stripe Webhook endpoint has updated.');
}
} else { // 同じURLのエンドポイントがない場合、新規に作成する
const webhookEndpoint = await stripe.webhookEndpoints.create({
url,
enabled_events: enabledEvents
});
console.log('New Stripe Webhook endpoint has created.');
console.log(webhookEndpoint);
// エンドポイントの署名シークレットをJSONとして出力(署名シークレットを動的に取得できるのは作成時のみ)
fs.writeFileSync(path.resolve(__dirname, '../data/stripe_webhooks_endpoint.json'), JSON.stringify({ SECRET: webhookEndpoint.secret }));
}
}
})();
IPアドレスリストを取得する処理と同じように、predeploy
にスクリプトを追加します。
この際、$GCLOUD_PROJECT
変数で現在のプロジェクトIDが使用できます。
参考:Firebase CLI リファレンス
https://firebase.google.com/docs/cli?hl=ja#environment_variables
{
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"curl https://stripe.com/files/ips/ips_webhooks.json --output functions/data/stripe_webhooks_ips.json",
"node functions/scripts/createStripeWebhookEndpoint.js --project $GCLOUD_PROJECT"
]
}
}
3. エンドポイント関数で各種定数、署名シークレットを使う
今までの処理を総合してこのように関数を記述します。
エンドポイントの作成時に署名シークレットをJSONとして出力しているので、署名チェックの際はこれを使います。
また、リージョンは定数で設定したものをfunctions.region
メソッドを使って指定します。
const functions = require('firebase-functions');
const firebase = require('firebase-admin');
const requestIp = require('request-ip');
const stripeWebhookIps = require('./data/stripe_webhooks_ips.json').WEBHOOKS;
const stripeWebhookEndpointSecret = require('./data/stripe_webhooks_endpoint.json').SECRET;
const StripeEvents = require('./constants/StripeEvents');
const Config = require('./constants/Config');
const stripe = require('stripe')(Config.STRIPE_SECRET_KEY);
firebase.initializeApp();
const db = firebase.firestore();
exports[Config.STRIPE_WEBHOOK_PATH_NAME] = functions
.region(Config.STRIPE_WEBHOOK_REGION) // 関数のリージョンを指定
.https.onRequest((request, response) => {
const clientIp = requestIp.getClientIp(request);
if (stripeWebhookIps.indexOf(clientIp) === -1) {
return response.status(403).send('Webhook Error: Invalid IP Address');
}
const sig = request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(request.rawBody, sig, stripeWebhookEndpointSecret);
} catch (err) {
return response.status(400).send(`Webhook Error: ${err.message}`);
}
db.collection('/stripe_events').doc(event.id).set(
Object.assign({
createdAt: firebase.firestore.Timestamp.fromMillis(event.created * 1000)
}, event)
);
// イベントのタイプに応じて処理を行う
switch (event.type) { // イベントのタイプに応じて処理を行う
case StripeEvents.PAYMENT_INTENT_SUCCEEDED: { // 例)PaymentIntentによる決済成功時
const paymentIntent = event.data.object; // PaymentIntentのインスタンスを取得
console.log(paymentIntent);
response.json({ received: true }); // ステータス200でレスポンスを返却
break;
}
case StripeEvents.PAYMENT_METHOD_ATTACHED: { // 例)PaymentMethodがカスタマーに紐づけられた時
const paymentMethod = event.data.object; // PaymentMethodのインスタンスを取得
console.log(paymentMethod);
response.json({ received: true }); // ステータス200でレスポンスを返却
break;
}
default: { // 想定していないイベントが通知された場合
return response.status(400).end(); // ステータス400でレスポンスを返却
}
}
});
デプロイの際、既存のエンドポイント関数のリージョンが定数ファイルで指定しているasia-northeast1
でなかった場合は既存のものを削除するかどうか問われるはずなので、Yesを選択してください。
(また、eslintで、warning Expected to return a value at the end of arrow function
が出る場合はよければfunctions/.eslintrc.json
のconsistent-return
をoff
にしてください。)
ダッシュボードで確認してみると、指定したリージョン、関数名、イベントタイプでエンドポイントが作成されているのが確認できます。これで、受信するイベントタイプの設定が容易にできるようになりました。
終わり
詳しい情報は公式のドキュメント・APIリファレンスを参照してください。
ドキュメント
https://stripe.com/docs/webhooks
Webhook Endpoint API
https://stripe.com/docs/api/webhook_endpoints
Webhookイベントの一覧
https://stripe.com/docs/api/events/types