Stripe の Webhook を冪等性を担保して受け取る


## Stripe とは

決済に関する様々なサービスを提供していて、HTBのWebサービスのクレジット決済はすべてStripeを活用して実装しています。

簡単に実装できる点もそうですが、

- ドキュメントが本当に Developer Friendly
- 日本語での公式サポートが本当に丁寧で手厚い
- ダッシュボードが見やすい。今後の進化にも期待。

などなど、大好きなサービスです。

[#JP_Stripes](https://jpstripes.connpass.com/) という勉強会コミュニティが各地で開催されているので、気になる方はぜひ。

## Webhook

Webhook を使うと、Stripe で何かしらの処理が行われると、そのイベントの結果をエンドポイントに対して通知することが可能です。

例えば、決済が成功すると、`payment_intent.succeeded` というイベントが発生し、事前にStripe側に設定した Webhook エンドポイントにおいて、JSONの形でデータを受け取ることが可能になります。

動画配信サービス「hod」での動きをざっくり説明すると

1. クレジットカードでの決済が成功する
2. Webhook を API Gateway が受け取り、後段の Step Functions が起動する
3. ステートマシンの中で、動画の視聴ライセンスの付与や、経理用のデータを非同期で DynamoDB に作成する

みたいなことをやっています。
（他にもいろいろやっているのですが、主題から離れてしまうので、割愛します）

https://stripe.com/docs/webhooks?locale=ja-JP

実装上の注意点については、上記のドキュメントにも詳しく書いているのですが、今回のとりあげるのは、その中でも
 **このWebhookは複数回受信する可能性がある** という特徴に対する対策方法です

## イベント処理を冪等にする

イベントが重複する可能性がある点について、ドキュメントには以下のように記載があります。

> Webhook エンドポイントは、同じイベントを複数回受信する可能性があります。イベント処理を冪等にすることで、重複するイベントの受信に対処することができます。
> これを実施する 1 つの方法として、処理したイベントをログに記録し、すでにログに記録したイベントを処理しないようにする方法があります。

Stripeさんがドキュメントに書いてある方法については、ポピュラーな方法なようで、
札幌で開催された JP_Stripes で、参加者のみなさんとディスカッションした時も、同様の形での実装がいいんじゃないか、と言われました。
AWSでいうと ElastiCache あたりにいれるといいんじゃないかと、

### キャッシュにイベントを記録しておく方法

Webhook の重複イベントに関しては、TTLを設定した形で ElastiCache あたりにイベントを保存しておき、
そこを参照してから後段の処理を進める。という構成です。

しかし、我々開発チームは「VPCの内側のサービスは使わない」という鉄の掟があるので、採用することができません。


### DynamoDB Streams を使う方法

そこで、次に DynamoDB イベントのログを保存する方法を考えてみました。
`payment_intent.succeeded` というイベントを例にとると、PaymentIntent オブジェクトは以下ような形になっています。

```json: THE PAYMENTINTENT OBJECT
{
  "id": "pi_3MtwBwLkdIwHu7ix28a3tqPa",
  "object": "payment_intent",
  "amount": 2000,
  "amount_capturable": 0,
  "amount_details": {
    "tip": {}
  },
  ...(省略)
}
```

`pi_` から始まるものはユニークなIDなので、DynamoDB のプライマリキー として使えそうです。

こうしてデータをDynamoDBに保存して、イベント受信時に PaymentIntent の ID を見てデータの重複を確認。
データがなかったら保存して後段の処理へ続き、IDが重複していたら、その処理を止める。

というロジックを作ることができそうです。

イベント受信後のロジックに関しては、ストリームデータを活用することにしました。
DynamoDB には　DynamoDB Streams という、変更データをストリームデータとして流してくれます。
PaymentIntent の　ID を プライマリキー として DynamoDB に Create すると、
DynamoDB Streams から そのデータを使って Lambda が起動し、その後段の Step Functions を起動することができます。

そして、重複したイベントを受信した際は、既存のプライマリキーを指定しての DynamoDB の Create は失敗に終わるので、
その後段で、DynamoDB Streams も発生しない。ということ形になり、冪等性を担保することができます。

ここまで、実装して、実際に開発環境で動かしながらメンバーと話している時に気が付きました。

**「あれ、要は重複したデータがDynamoDBに作られなきゃいいんですよね？それじゃ、、、」**

今回の構成では、Webhook イベントのログを保存する DynamoDB や Streams を受け取る Lambda など、新しくリソースを増やさなければなりませんが、もっとシンプルな方法での解決方法を思いついたため、試してみることにしました。

### DynamoDB の 条件付き書き込みを使う。

「hod」の決済処理では、実決済の前に、ユーザごとにライセンスレコードを作り、そのENUMの中身を見て、ユーザ単位に資料が可能かどうか。というのを判断しています。
前処理が終わっていると`Granting`でレコードを作成し、実決済が終わると`Granted`にアップデートしています。

この値の条件を `ConditionExpression`に記載してあげると、この条件を満たすときだけデータの更新が成功します。

```JavaScript: updateItem の一部
ConditionExpression: '#LicensingState = :Granting';
```

実際には Webhook を受けて起動する Step Functions の中の Lambda でこの処理を行っていて、条件付き書き込みの成功失敗を受けて分岐をしています。

また、このようなビジネスロジック上明確区別ができる値がなかったとしても、`updateAt`のような時間の大小比較などでも、条件をつけることができるので、他の場面でも使えそうです。

```JavaScript: updateItem の一部(時間で条件をつける例)
ConditionExpression: '#updatedAt < :2023-01-01T00:00:00.000Z';
```

AWS SDK for JavaScript v3 のドキュメントはこちら

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-client-dynamodb/Interface/UpdateItemInput/

## まとめ

このように Webhook が複数回飛んできた場合の処理についてですが、DynamoDBの条件付き書き込みを利用して、2回目以降に飛んできたデータを条件から外すことによって、書き込み自体を失敗させ、冪等性を担保することができました。

どのような場面でも通用する方法ではないかと思いますが、我々の構成では、管理が必要なリソースを増やすことなく、実現することができました。

みなさん、サーバレスでの冪等性の担保ってどうやって構築されていますか？