LoginSignup
38
23

More than 3 years have passed since last update.

Cloud Functions for FirebaseでStripeのWebhookエンドポイントを作成

Last updated at Posted at 2020-02-14

はじめに

決済サービス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等)が存在する状態を前提として、以下のように関数を追加します。

functions/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.png

送信イベントの種類は任意ですが、ひとまず「すべてのイベントを受信」をクリックすることで、通知することが可能なイベントがすべて指定したエンドポイントに送信されることになります。

ダッシュボード上でエンドポイントの追加が完了したら、詳細ページから「テストのWebhookを送信」ボタンを押して先ほどの関数内で対応するようにしたpayment_intent.succeededpayment_method.attachedを選択し送信してみます。あるいは、実際にAPIを使って決済処理を行ってみます。私はExpoアプリで実装した決済フローがあるのでそちらで確認しました。

ダッシュボードからイベントを指定しテスト送信してみるとこのような感じでCloud Functionsでの受信が成功しました。
webhook.png

FirebaseのコンソールからFunctionsのログを見ると、PaymentMethodインスタンスの内容が取得できているのがわかります(関数のconsole.log部分による)。
webhook.png

一方イベントを先ほどの関数で処理していないものに変えると、400を返しているのでエラーになります。
webhook.png

Firestoreと連携してみる

あとは受信したイベントを使って何をしてもいいわけですが、ここではひとまずそのままFirestoreに保存することを考えてみます。
イベントを受信したらstripe_eventsコレクションにイベントの内容をドキュメントとして追加します。
一つのイベントがエンドポイントに送信される回数は一回とは保証されていないようなので(これはFirestoreに限らずすべての処理に共通するため注意が必要です)、
冪等性を保証するために、以下のようにドキュメントIDはイベントのIDを使うことにします。
また、createdプロパティにイベント発生時間(秒)が入っているので、Firestoreで扱いやすくするためTimestamp型でcreatedAtとして登録しておくとします。

functions/index.js
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コンソールで確認すると、イベントの内容がドキュメントとして保存されているのが確認できました。

Test_Project_–_Firebase_console.png

イベント送信に関する注意事項

上述した冪等性の件を含めて、エンドポイントの実装に際して以下のような注意点があります。

  • 一つのイベントが複数回送信される可能性がある(関数の冪等性に注意)
  • エンドポイントからのレスポンスはなるべく即時行う(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ダッシュボードの各エンドポイントページから取得し、

webhook.png

APIシークレットキーはメニューの「開発者」「APIキー」ページから取得します。

Cloud Functionsのディレクトリでstripeパッケージをインストールしておきます。

$ cd functions
$ npm install --save stripe

エンドポイントの関数では、リクエストボディ、前述の署名ヘッダー、署名シークレットを使用してEventオブジェクトを初期化します。この処理を踏むことで、適切な署名ヘッダーが存在していないリクエストを無効化することができます。

functions/index.js
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
functions/index.js
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スクリプトを追加するとデプロイ前に実行してくれます。

firebase.json
{
  "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することができます。

functions/index.js
// 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アドレスによる制限ができているのが確認できます。

Postman.png

エンドポイントとイベントタイプの管理

エンドポイントに送信されるイベントのタイプは必要なもののみ指定することが望ましいですが、サービス側(関数側)で必要なイベントとダッシュボードでの設定を合わせるのは少し面倒な気もします。
エンドポイントの作成や設定はダッシュボードからだけでなくREST APIやNode.jsパッケージによって動的に行うことができるようなので、最後にこれを試してみます。

実装の概要

以下のような感じで。

  1. エンドポイント(関数)のリージョン名、関数名、受信するイベントタイプ一覧等をCloud Functions側の定数として管理する。
    定数の管理方法はFirebaseの環境変数や.env等でもよいですが、ここではわかりやすいようにJSファイルにします。
  2. 関数のデプロイ時(predeploy)にエンドポイントの作成・更新を動的に行う。
  3. Cloud Functions側およびエンドポイントの作成・更新処理において、共有された1.の定数を使用する。

1. 定数ファイルを作成

関数をデプロイするリージョン名、関数名、ついでにシークレットキーを定数化。

functions/constants/Config.js
exports.STRIPE_WEBHOOK_REGION = 'asia-northeast1';
exports.STRIPE_WEBHOOK_PATH_NAME = 'stripe_webhook';
exports.STRIPE_SECRET_KEY = 'sk_test_...';

関数側で使用するイベントを定数化。同時にtypo防止になります。

functions/constants/StripeEvents.js
exports.PAYMENT_INTENT_SUCCEEDED = 'payment_intent.succeeded';
exports.PAYMENT_METHOD_ATTACHED = 'payment_method.attached';

2. エンドポイントの作成・更新処理

Node.jsでエンドポイントを作成する処理を実装します。使用しているモジュールは適宜インストールしてください。

functions/scripts/createStripeWebhookEndpoint.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

firebase.json
{
  "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メソッドを使って指定します。

functions/index.js
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.jsonconsistent-returnoffにしてください。)

ダッシュボードで確認してみると、指定したリージョン、関数名、イベントタイプでエンドポイントが作成されているのが確認できます。これで、受信するイベントタイプの設定が容易にできるようになりました。
webhook.png

終わり

詳しい情報は公式のドキュメント・APIリファレンスを参照してください。

ドキュメント
https://stripe.com/docs/webhooks

Webhook Endpoint API
https://stripe.com/docs/api/webhook_endpoints

Webhookイベントの一覧
https://stripe.com/docs/api/events/types

38
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
23