StripeのWebhookイベントをSymfonyのWebhookコンポーネントで受け取る

[Stripe / JP_Stripes Advent Calendar 2024](https://qiita.com/advent-calendar/2024/stripe) 14日目の記事です。

StripeにはWebhookが用意されており、各種イベント時にURLを指定することで自分のサービスにイベントを通知することができます。
Symfonyは、[Webhook](https://qiita.com/ippey_s/items/2bac555da5edad6f0748)というコンポーネントが用意されており、このコンポーネントを使うことで簡単にWebhookを作ることができます。

## Stripe側の準備

:::note warn
記事内のリンクはテスト環境へのリンクです！
:::

Stripeのダッシュボードの『開発者』ページに、[Webhook](https://dashboard.stripe.com/test/webhooks) というページがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/448bfbce-114d-077b-90a8-bf93481d2f72.png)

ここの `エンドポイントを追加` をクリックして、Webhookの設定を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/74ffe03e-1f11-08a0-8054-ef5d050ac61b.png)

本番環境や、開発環境が外部に公開されている場合は、`エンドポイントURL` に、イベントを受け取るURLを設定することで、Stripeのイベントを受け取ることができます。受け取るイベントは `りっすんするイベントの選択` から選択します。

開発環境がローカルの場合は `ローカル環境でテスト` をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/94296dff-8a71-2ba1-ce23-08d2c05df6c4.png)

するとこのような画面になります。ローカル環境にStripe CLIをインストールし、そのコマンドを実行すればテストが行えます。

```
stripe listen --forward-to [URL]
```

![スクリーンショット 2024-12-14 8.43.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/d63fad70-7809-5352-8ad1-6733e0775c9b.png)

実行すると、このように画面上に `secret` という文字列が表示されます。この `secret` は開発時に利用するので、コピーしておきます。

これでStripe側の準備は完了です。

## SymfonyでWebhookを作る

:::note warn
Symfonyのインストールは省略します。
:::

### 必要なライブラリのインストール

まずは必要なライブラリをインストールしていきます。

```shell
composer require stripe/stripe-php # Stripe SDK
composer require webhook # Webhookコンポーネント
composer require maker --dev # クラスのスケルトン作成コンポーネント
```

### クラスの作成

Webhookは1からつくるとちょっとめんどくさいので、 `Maker` コンポーネントを使って必要なクラスやメソッドを作ってもらいます。

```shell
bin/console make:webhook
```

実行すると、質問が表示されるので答えます。
```shell
 Name of the webhook to create (e.g. github, stripe, ...):
 > stripe # webhookの名前


 Add a RequestMatcher (press <return> to skip this step) [<skip>]:
  [0] <skip>
  [1] Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher
  [2] Symfony\Component\HttpFoundation\RequestMatcher\HostRequestMatcher
  [3] Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher
  [4] Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher
  [5] Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher
  [6] Symfony\Component\HttpFoundation\RequestMatcher\PathRequestMatcher
  [7] Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher
  [8] Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher
 > 0 # どこからアクセスされたかを調べるためのRequestMatcher。とりあえず0

 created: src/Webhook/StripeRequestParser.php
 created: src/RemoteEvent/StripeWebhookConsumer.php
 updated: config/packages/webhook.yaml

           
  Success! 
           

```

作成されたWebhookは `{ドメイン}/webhook/{作ったWebhookの名前}` でアクセスできます。上記のコマンドで作成したWebhookは、ローカル環境上だと `http://localhost:8000/webhook/stripe` となります。

### WebhookConsumerで受け取ったイベントを処理する

`WebhookConsumer` はStripeから受け取ったイベントの内容を処理する部分です。ここにお好みのロジックを記述します。
上記のコマンドで作成されたプログラムは以下のようになっています。

```php:StripeWebhookConsumer.php
<?php

namespace App\RemoteEvent;

use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;

#[AsRemoteEventConsumer('stripe')]
final class StripeWebhookConsumer implements ConsumerInterface
{
    public function __construct()
    {
    }

    public function consume(RemoteEvent $event): void
    {
        // Implement your own logic here
    }
}
```

`consume()` メソッドで渡された `RemoteEvent` にStripeから渡されたペイロードが配列で入っています。この値を使って、色々な処理ができます。
ここでは、ログに出力してみます。

```php
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function consume(RemoteEvent $event): void
    {
        $payload = $event->getPayload();
        $message = sprintf('%d円売り上げました！', $payload['data']['object']['amount']);
    }
```

これで、Stripeから受け取ったイベントを処理する部分はできました！

### RequestParserでどのイベントを受け取るか決める

`RequestParser` は、Stripeから受け取ったイベントを解析し、イベントを処理するかどうかを決めます。

```php:StripeRequestParser.php
<?php

namespace App\Webhook;

use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Exception\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

final class TestRequestParser extends AbstractRequestParser
{
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            // Add RequestMatchers to fit your needs
        ]);
    }

    /**
     * @throws JsonException
     */
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        // TODO: Adapt or replace the content of this method to fit your need.

        // Validate the request against $secret.
        $authToken = $request->headers->get('X-Authentication-Token');

        if ($authToken !== $secret) {
            throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid authentication token.');
        }

        // Validate the request payload.
        if (!$request->getPayload()->has('name')
            || !$request->getPayload()->has('id')) {
            throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request payload does not contain required fields.');
        }

        // Parse the request payload and return a RemoteEvent object.
        $payload = $request->getPayload();

        return new RemoteEvent(
            $payload->getString('name'),
            $payload->getString('id'),
            $payload->all(),
        );
    }
}
```

`getRequestMatcher()` でどのリクエストを対象とするか、 `doParse()` でデータの解析・検証を行い、問題なければ `RemoteEvent` としてイベントを発火し、先ほどの `WebhookConsumer` を実行します。

`doParse()` で解析と検証を行いますが、基本的には

```php
    /**
     * @throws JsonException
     */
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $payload = $request->getPayload();

        return new RemoteEvent(
            'stripe',
            $payload->getString('id'),
            $payload->all(),
        );
    }
```

このようにすればOKです。`RemoteEvent`の1つ目の引数はWebhookのイベント名なので、ここでは `stripe` を指定します。2つ目の引数にはpayloadのidを、3つ目にはpayload自体を渡します。


扨。
このままだと、 `/webhook/stripe` にアクセスすれば、いくらでもイベントを発火できます。そこで、 `getRequestMatcher()` を使って、アクセス制限をかけます。

```php
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new IsJsonRequestMatcher(),
            new MethodRequestMatcher('POST'),
        ]);
    }
```

`IsJsonRequestMatcher` はリクエストボディがJSONである場合、 `MethodRequestMatcher` は引数のメソッドである場合という意味で、 この条件に一致した場合のみ `doParse()` を実行することができます。

では実行してみましょう！
Stripe設定時に実行した `stripe listen` とは別のターミナルを用意します。


```shell
php -S localhost:8000 -t public # ローカルウェブサーバ起動
stripe trigger charge.succeeded
```

1つ目はローカルウェブサーバの起動、2つ目はイベント発火です。上記コマンドを実行すると、イベントが発火されたこととなり、リッスン中のターミナルに結果が出力されます。今回は `charge.succeeded` を発火してみました。

ではイベント発火した結果リッスン側のターミナルはどうなったでしょうか。

```shell
2024-12-14 17:56:31   --> charge.succeeded [evt_3QVr9GIRhJ0AtimP1L7ESype]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1L7ESype]
2024-12-14 17:56:31   --> payment_intent.succeeded [evt_3QVr9GIRhJ0AtimP1Dih2i9e]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1Dih2i9e]
2024-12-14 17:56:31   --> payment_intent.created [evt_3QVr9GIRhJ0AtimP1ksMVZxN]
2024-12-14 17:56:31  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVr9GIRhJ0AtimP1ksMVZxN]

```

おっと。イベントが3つ発火されており、その全てを受け取って処理しようとしています。ログも3行出力されています。


```
100円売り上げました！
100円売り上げました！
100円売り上げました！
```

`charge.succeeded` を発火しましたが、その他 `payment_intent.created`, `payment_inten.succeeded` も発火しています。この時はRemoteEventの作成してWebhookConsumerの実行をして欲しくありません。
そこで `doParse()` に一手間加えます。


### 実行して良いものだけ選りすぐる

`doParse()` 内で、実行したイベントのタイプを判断してRemoteEventを作成しないようにします。
ここで今回役立つのが引数の2つめにある `$secret` です。　ここには `config/packages/webhook.yaml` に設定した `secret` の値が注入されます。先ほどリッスンコマンドを実行した際に控えた値を、このYAMLに記述します。


```yaml:webhook.yaml
framework:
  webhook:
    routing:
      stripe:
        service: App\Webhook\StripeRequestParser
        secret: {ここに控えたsecretの値を記述}
```

この値を元に、Stripeからアクセスされたかをチェックし、合わせてイベントのタイプも確認します。

```diff:StripeRequestParser.php
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
    {
        $payload = $request->getPayload();

+       try {
+           $signature = $request->headers->get('stripe-signature'); // HTTP_STRIPE_SIGNATUREの値
+           $stripeEvent = Webhook::constructEvent($request->getContent(), $signature, $this->secret);
+           if ($stripeEvent->type !== 'charge.succeeded') {
+               throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request type is not matched.');
+           }
+       } catch (UnexpectedValueException|SignatureVerificationException) {
+           throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request secret is invalid.');
+       }

        return new RemoteEvent(
            'stripe',
            $payload->getString('id'),
            $payload->all(),
        );
    }
```

`Webhook::constructEvent()` は、Stripe SDKのWebhookEventのオブジェクトを作成します。引数は以下のとおりです。


| 引数 | 値 |
|---|---|
| 1つめ | ペイロードの**文字列** |
| 2つめ | HTTP_STRIPE_SIGNATUREの値 |
| 3つめ | secret |

:::note
Symfonyの `Request` オブジェクトの `getPayload()` は配列になっているので、そのままの値を渡すために `getContent()` を使います。
:::

WebhookEventはタイプを持っているため、その値とイベント発火したいタイプが一致しているか確認します。
WebhookEventが作れなかったり、タイプが一致しない時は `RejectWebhookException` をスローします。引数に `Response::HTTP_BAD_REQUEST` を渡すと、ステータスコード400を返します。

こうすることで、Stripeから指定のタイプのイベントにのみ反応してWebhookの処理を実行できます。

```
2024-12-14 21:08:44   --> payment_intent.succeeded [evt_3QVu9GIRhJ0AtimP0MJPxxiN]
2024-12-14 21:08:44   --> payment_intent.created [evt_3QVu9GIRhJ0AtimP0kldm8zQ]
2024-12-14 21:08:44   --> charge.succeeded [evt_3QVu9GIRhJ0AtimP0aDcpFV3]
2024-12-14 21:08:44  <--  [400] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0MJPxxiN]
2024-12-14 21:08:45  <--  [400] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0kldm8zQ]
2024-12-14 21:08:45  <--  [202] POST https://localhost:8000/webhook/stripe [evt_3QVu9GIRhJ0AtimP0aDcpFV3]
```

また、実行イベントはStripe上の[イベントリスト](https://dashboard.stripe.com/test/events)でも確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9882/d2132b5a-b853-21a2-3efe-4e1cd1376472.png)


## まとめ

このようにSymfonyでは、簡単にStripe用のWebhookを作成することができます。通知や集計などいろいろなことに利用できます！