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

# はじめに

決済サービスStripeでは[Webhooks](https://stripe.com/docs/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等）が存在する状態を前提として、以下のように関数を追加します。

```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の[ダッシュボード](https://dashboard.stripe.com/)の左メニュー内「開発者」「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`

<img width="1440" alt="webhook.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/55e4a2e8-1284-e5df-ca9b-d35213e96188.png">

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

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

ダッシュボードからイベントを指定しテスト送信してみるとこのような感じでCloud Functionsでの受信が成功しました。
<img width="1440" alt="webhook.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/d6aa94a9-a730-1752-30a2-082e135910cc.png">

FirebaseのコンソールからFunctionsのログを見ると、PaymentMethodインスタンスの内容が取得できているのがわかります（関数の`console.log`部分による）。
![webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/c2fde3bf-e1ee-5659-f440-24d3bffeeffa.png)

一方イベントを先ほどの関数で処理していないものに変えると、400を返しているのでエラーになります。
<img width="1440" alt="webhook.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/95fbca00-5e37-1c1a-ef38-4cce2b2d847f.png">

# Firestoreと連携してみる

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

```js: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/fd2bbc7f-9ccb-0fb3-8898-5a7fa1ce75b0.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/a39873ec-3848-d9c7-bf8e-154bac235a32.png)

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

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

```console
$ cd functions
$ npm install --save stripe
```

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

```js: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アドレスを取得するパッケージを導入し、

```console
$ npm install --save request-ip
```

```js: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アドレスのリストは上記のようにハードコーディングせず、定数として別にファイル化しておいてもいいですが、[テキストファイル](https://stripe.com/files/ips/ips_webhooks.txt)および[JSONファイル](https://stripe.com/files/ips/ips_webhooks.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スクリプトが以下のように実行されたら成功です。

```console
$ 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アドレスによる制限ができているのが確認できます。

<img width="976" alt="Postman.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/31c495a6-49f9-4aae-92a1-8612bb901f25.png">

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

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

### 実装の概要

以下のような感じで。

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

### 1. 定数ファイルを作成

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

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

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

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

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

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

```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

```json: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`メソッドを使って指定します。

```js: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.json`の`consistent-return`を`off`にしてください。）

ダッシュボードで確認してみると、指定したリージョン、関数名、イベントタイプでエンドポイントが作成されているのが確認できます。これで、受信するイベントタイプの設定が容易にできるようになりました。
![webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/aff94fbf-ffe2-2879-af7e-9a34d10e9cf1.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