Stripe / JP_Stripes Advent Calendar 2024 14日目の記事です。
StripeにはWebhookが用意されており、各種イベント時にURLを指定することで自分のサービスにイベントを通知することができます。
Symfonyは、Webhookというコンポーネントが用意されており、このコンポーネントを使うことで簡単にWebhookを作ることができます。
Stripe側の準備
記事内のリンクはテスト環境へのリンクです!
Stripeのダッシュボードの『開発者』ページに、Webhook というページがあります。
ここの エンドポイントを追加
をクリックして、Webhookの設定を行います。
本番環境や、開発環境が外部に公開されている場合は、エンドポイントURL
に、イベントを受け取るURLを設定することで、Stripeのイベントを受け取ることができます。受け取るイベントは りっすんするイベントの選択
から選択します。
開発環境がローカルの場合は ローカル環境でテスト
をクリックします。
するとこのような画面になります。ローカル環境にStripe CLIをインストールし、そのコマンドを実行すればテストが行えます。
stripe listen --forward-to [URL]
実行すると、このように画面上に secret
という文字列が表示されます。この secret
は開発時に利用するので、コピーしておきます。
これでStripe側の準備は完了です。
SymfonyでWebhookを作る
Symfonyのインストールは省略します。
必要なライブラリのインストール
まずは必要なライブラリをインストールしていきます。
composer require stripe/stripe-php # Stripe SDK
composer require webhook # Webhookコンポーネント
composer require maker --dev # クラスのスケルトン作成コンポーネント
クラスの作成
Webhookは1からつくるとちょっとめんどくさいので、 Maker
コンポーネントを使って必要なクラスやメソッドを作ってもらいます。
bin/console make:webhook
実行すると、質問が表示されるので答えます。
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
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から渡されたペイロードが配列で入っています。この値を使って、色々な処理ができます。
ここでは、ログに出力してみます。
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
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()
で解析と検証を行いますが、基本的には
/**
* @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()
を使って、アクセス制限をかけます。
protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new IsJsonRequestMatcher(),
new MethodRequestMatcher('POST'),
]);
}
IsJsonRequestMatcher
はリクエストボディがJSONである場合、 MethodRequestMatcher
は引数のメソッドである場合という意味で、 この条件に一致した場合のみ doParse()
を実行することができます。
では実行してみましょう!
Stripe設定時に実行した stripe listen
とは別のターミナルを用意します。
php -S localhost:8000 -t public # ローカルウェブサーバ起動
stripe trigger charge.succeeded
1つ目はローカルウェブサーバの起動、2つ目はイベント発火です。上記コマンドを実行すると、イベントが発火されたこととなり、リッスン中のターミナルに結果が出力されます。今回は charge.succeeded
を発火してみました。
ではイベント発火した結果リッスン側のターミナルはどうなったでしょうか。
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に記述します。
framework:
webhook:
routing:
stripe:
service: App\Webhook\StripeRequestParser
secret: {ここに控えたsecretの値を記述}
この値を元に、Stripeからアクセスされたかをチェックし、合わせてイベントのタイプも確認します。
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 |
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上のイベントリストでも確認できます。
まとめ
このようにSymfonyでは、簡単にStripe用のWebhookを作成することができます。通知や集計などいろいろなことに利用できます!