初めてのStripe: 完全にサーバーレスのチケット販売
[ This post is available in English here. ]
本ブログは2021年Stripe Advent Calendar 2021の12月3日分のエントリーです。
皆さん、こんにちは!Tokyo Demo Fest実行委員のテッダー マイケルです。AWSとの9年間の開発経験を活かしながら、JAWS-UG札幌支部とJAWS-UGのコミュニティイベント(FESTA 2019、DAYS 2021、PANKRATION 2021)の運営を手伝いしています。2020年にAWSコミュニティビルダーに認定されました。
今回はTokyo Demo Fest 2021のチケット販売のため、AWS上で初めてStripeを実装した話を詳しくご紹介します。Stripeをまだ触っていない方でもわかりやすく伝えたいと思います。サンプルコードの言語はNode.js 14.xになります。
Tokyo Demo Festについて
Tokyo Demo Fest (略: TDF)は日本で唯一のデモパーティです。デモパーティは、コンピュータを用いたプログラミングとアートに興味のある人々が日本のみならず、世界中から一堂に会し、デモ作品のコンペティション(コンポ)やセミナーなどを行います。また、イベント開催中は集まった様々な人たちとの交流が深められます。
デモについての解説はこちらをご参照ください。
過去のTDFのチケット販売はPayPalで行いましたが、今年からはようやくStripeに移行することができました。今回は実装がとても簡単なStripe Checkoutを利用し、実際のコーディングは数時間程度で対応できました。
システム設計
こちらが全体図です。今回はStripeに限る話だけなので、ライブ配信周りなどは描かれてません。Stripeとその関係するシステムのみになります。
Visitorは最初にAmplifyで公開されているTDFのWebサイトから入ります。2種類のチケットがあるのでどちらかを選択し、Stripe Checkoutへ移行します。決済完了時はStripeからのWebhookが呼び出され、チケット情報とVotekeyがStripeでの購入時に入力されたメールアドレスに送信されます。Visitorはメールで配布されたVotekeyを使い、Wuhuパーティシステム (ECS/Fargate)にログインできるようになります。
Stripe商品の作成
今回のTDFではチケットを2種類販売しています。
- Visitor Ticket(1,000円)
- Bronze Supporter (10,000円、Tシャツ無料配送込み)
Visitor TicketはStripeでは1つの「商品」になるので、簡単ですね。
Bronze SupporterはVisitor Ticketと金額が違うため別の商品が必要ですが、Tシャツのサイズ(S/M/L/XL)の選択もあるので、サイズ別の4つの商品を作成しました。Tシャツはチケット代に含まれているので、それぞれのサイズの金額は「ゼロ円」に設定しています。そしてTシャツは配送になるため、購入時にVisitorの住所入力が必要です。
Stripeで配送情報を入力させるためには「配送料金」を作成する必要があります。今回はTシャツの配送料もチケット代に含まれているため、こちらの金額も「ゼロ円」に設定しています。
Stripe APIキーのセキュリティ
Stripe APIキーは秘密情報なので、ソースコードに書き込んだりすると情報漏えいの要因になります。私のいつもの実装パターンですが、Lambdaを使ってる時はAWS Systems ManagerのParameter StoreにAPIキー等を保存することにしています。
APIキー、Webhookシークレット、商品のPriceID、配送料金IDなどは1つずつ別々のSecureStringに保存が可能ですが、実はStandardでも4KBまで保存が可能なので、すべての必要な情報をJSON化にし、1つの文字列として保存するのがとても楽です。
以下、キーの内容は隠してますが、実際保存してるデータがこんな感じです。
{"stripe_api_secret_key":"sk_test_51JU2XXXXXXXXXXXX",
"webhook_signing_secret":"whsec_TqW4TXXXXXXXXXXXX",
"product_visitor_ticket":"price_1JX1zXXXXXXXXXXXX",
"product_bronze_ticket":"price_1JrKaXXXXXXXXXXXX",
"product_tshirt_s":"price_1JrKlXXXXXXXXXXXX",
"product_tshirt_m":"price_1JrKmXXXXXXXXXXXX",
"product_tshirt_l":"price_1JrKnXXXXXXXXXXXX",
"product_tshirt_xl":"price_1JrKoXXXXXXXXXXXX",
"success_url":"https://tokyodemofest.jp/success.html",
"cancel_url":"https://tokyodemofest.jp#registration",
"shipping_rate":"shr_1JrKNXXXXXXXXXXXX",
"shipping_countries":"US,JP,IE,GB,NO,SE,FI,RU,PT,ES,FR,DE,CH,IT,PL,CZ,AT,HU,BA,BY,UA,RO,BG,GR,AU,NZ,KR,TW,IS"}
Lambdaが実行されてる際、このJSONをパラメータストアから読み込むためには Lambdaに内蔵されてる aws-sdk
を使います。Stripeの初期化にはAPIキーを渡すのが必要なので、SSMからconfigを引っ張ってから初期化を行います。
const loadConfig = async function() {
const aws = require('aws-sdk');
const ssm = new aws.SSM();
const res = await ssm.getParameter( { Name: '/tdf/config-stripe', WithDecryption: true } ).promise();
return JSON.parse(res.Parameter.Value);
}
exports.handler = async (event) => {
const config = await loadConfig();
const stripe = require('stripe')(config.stripe_api_secret_key);
// ...
}
HTML側の購入ボタン作成
参考のため、今回のTDFのHTML側の購入ボタンを軽く紹介します。
チケットの種類が2つなのですが、実はPOSTするエンドポイントが同じです。
Lambda側でチケットの違いをわかるためには <input type="hidden" name="type" value="bronze">
で判断しています。そして type=bronze
の場合はTシャツサイズの tshirt
指定もわかります。
Stripe CheckoutにBronze SupporterチケットとTシャツの商品指定は次のセクションで紹介します。
Stripe CheckoutへのURL生成
次はWebサイトでVisitorがチケット購入ボタンを押したら、Stripe Checkoutに移行させます。このURLには、どの商品を購入するとか、購入時に必要な情報(住所の入力が必要かどうか)が含まれています。URL生成はStripe SDKが行うので、URLが作成されたら、ブラウザに HTTP 303
(See Other) で転送されると、Stripe Checkoutのページが表示されます。
LambdaでCheckoutセッションのURLを生成し転送させるにはこんな感じです。
exports.handler = async (event) => {
const config = await loadConfig();
const stripe = require('stripe')(config.stripe_api_secret_key);
const session = await stripe.checkout.sessions.create( {
line_items: /* TODO */,
payment_method_types: [ 'card' ],
mode: 'payment',
success_url: config.success_url,
cancel_url: config.cancel_url
} );
const response = {
statusCode: 303,
headers: {
'Location': session.url
}
};
return response;
}
セッションデータの line_items
は商品を指定します。ブラウザからPOSTで送信されたデータがあるかどうかを確認し、 line_items
に入れる商品データを変えます。なお、LambdaではペイロードがBase64エンコードされてることがあるので、デコードを行う必要があります。
if (event.body) {
let payload = event.body;
if (event.isBase64Encoded)
payload = Buffer.from(event.body, 'base64').toString();
const querystring = require('querystring');
const res = querystring.parse(payload);
if ((res.type) && (res.type == 'bronze')) {
// ...
}
}
まずVisitorチケットの場合は単純に1つの商品になります。
let items = [ {
price: config.product_visitor_ticket,
quantity: 1
} ];
Visitorチケットの1つの商品でStripe Checkoutに転送するとこんな感じで表示されます。
では、次にBronze Supporterチケットの場合は選択されたTシャツサイズを商品のPriceIDとマッチングし、2つの商品(チケットの商品とTシャツの商品)を line_items
に入れます。
let tshirt_type = config.product_tshirt_s;
if (res.tshirt) {
if (res.tshirt == 's') tshirt_type = config.product_tshirt_s;
if (res.tshirt == 'm') tshirt_type = config.product_tshirt_m;
if (res.tshirt == 'l') tshirt_type = config.product_tshirt_l;
if (res.tshirt == 'xl') tshirt_type = config.product_tshirt_xl;
}
items = [ {
price: config.product_bronze_ticket,
quantity: 1
}, {
price: tshirt_type,
quantity: 1
} ];
あとは、Bronze Supporterチケットの場合はTシャツの配送に住所を入力してもらう必要があります。Checkoutセッションデータに配送料金の shipping_rates
と配送対象国(どの国への配送が可能)を shipping_address_collection
の allowed_countries
で指定します。
まとめるとこんな感じになります。
const session = await stripe.checkout.sessions.create( {
line_items: [ {
price: config.product_bronze_ticket,
quantity: 1
}, {
price: tshirt_type,
quantity: 1
} ],
payment_method_types: [ 'card' ],
mode: 'payment',
success_url: config.success_url,
cancel_url: config.cancel_url,
shipping_rates: [ config.shipping_rate ],
shipping_address_collection: {
allowed_countries: config.shipping_countries.split(',')
}
} );
配送情報がセッションデータに含まれるとStripe Checkoutで住所入力フィールドが一緒にフォームに表示されます。
一応、この数十行のコードだけでStripe Checkoutでの決済は可能になりました。購入決済が完了されたら、StripeからWebhookを呼び出し、メール送信などの他の処理が可能なので、次のセクションで紹介します。
Stripe Webhookの実装
先ほど紹介しましたが、TDFでは、チケット購入の決済完了となった時はVisitorへのチケット情報をメールで送信します。こちらの対応は別のLambda関数を作成し、API GatewayのURLをStripe Webhookに設定します。
Webhookの処理は各自それぞれ違う対応になりますので、StripeからのPOSTデータのデコードまで紹介します。
まずは、WebhookのURLが公開されているため、誰でもアクセスができてしまいます。Stripeからのアクセスの際はHTTPヘッダーに署名が含まれ、Webhookシークレットのキーでペイロードデータが正常なのかを確認します。
exports.handler = async (event) => {
// require Stripe signature in header
if (!event.headers['stripe-signature']) {
console.log('no Stripe signature received in header, returning 400 Bad Request');
return {
statusCode: 400
};
}
const sig = event.headers['stripe-signature'];
// require an event body
if (!event.body) {
console.log('no event body received in POST, returning 400 Bad Request');
return {
statusCode: 400
};
}
// decode payload
let payload = event.body;
if (event.isBase64Encoded)
payload = Buffer.from(event.body, 'base64').toString();
// construct a Stripe Webhook event
const config = await loadConfig();
const stripe = require('stripe')(config.stripe_api_secret_key);
try {
let ev = stripe.webhooks.constructEvent(payload, sig, config.webhook_signing_secret);
} catch (err) {
console.log('error creating Stripe Webhook event');
console.log(err);
return {
statusCode: 400
};
}
// ...TODO...
return {
statusCode: 200
};
}
Stripe Webhookのイベントまで正常に作成したら、次はCheckoutセッションのステータス変化を確認します。決済に関して以下の3つのイベントを対応するのが一般的です。
-
checkout.session.completed
: Stripe Checkoutで決済が行われました。支払い方法によって、決済が完了になってない可能性があります。クレジットカードの場合は基本的にpayment_status
がpaid
になるので、決済完了ということになります。 -
checkout.session.async_payment_succeeded
:completed
のイベントで決済が未完了だったのが、決済完了となりました。 -
checkout.session.async_payment_failed
:completed
のイベントで決済が未完了だったのが、決済失敗となりました。
この3つのイベントを対応するにはStripeのサンプルコードとほぼ同様に行ってます。
const createOrder = async function(session) {
// we (TDF) don't need to do anything here
}
const fulfillOrder = async function(session) {
// send ticket info to customer by email
console.log('customer email is: ' + session.customer_details.email);
}
const emailCustomerAboutFailedPayment = async function(session) {
// send email about failed payment
}
exports.handler = async (event) => {
// ...
const session = ev.data.object;
switch (ev.type) {
case 'checkout.session.completed':
// save an order in your database, marked as 'awaiting payment'
await createOrder(session);
// check if the order is paid (e.g., from a card payment)
// a delayed notification payment will have an `unpaid` status
if (session.payment_status === 'paid') {
await fulfillOrder(session);
}
break;
case 'checkout.session.async_payment_succeeded':
// fulfill the purchase...
await fulfillOrder(session);
break;
case 'checkout.session.async_payment_failed':
// send an email to the customer asking them to retry their order
await emailCustomerAboutFailedPayment(session);
break;
}
createOrder()
、fulfillOrder()
、そして emailCustomerAboutFailedPayment()
の3つの関数を実装することでWebhookの対応は完了になります。
もしWebhookがエラーで HTTP 2xx
以外のレスポンスを返された場合、Stripe側では時間を置いてから自動的にリトライされます。詳しくはStripe Webhook Best Practicesを確認してください。
ここまで実装ができてれば、Stripe Checkoutの対応は完了になります。おめでとうございます!
API GatewayのCustom Domainで複数エンドポイントをひとつに統合
今回の実装では、Stripe CheckoutのURL生成とStripe Webhookの2つのエンドポイントがあります。もちろんAPI Gatewayで払い出されたURL ( https://7q6f1e5os2.execute-api.ap-northeast-1...
)をそのまま使えますが、 stripe.tokyodemofest.jp
などの名前を付けられたサブドメインに統合するとURLの見た目が良くなります。
こんな感じで checkout
と fulfill
の2つのLambdaとAPI GatewayがCustom Domainの1つにまとめています。
TDFで初めてStripeを実装しての感想
正直、Stripe Checkoutをサーバーレスで実装するのはとても簡単でした。コードを書く量が少ないので、本当に数十行だけで自分のWebサイトから決済ができるようになります。
しかも、CheckoutやWebhookを実装してる際はStripe UIでAPIのHTTPリクエストとレスポンスとログ情報まで細かく見れて、Dashboardでグラフがとてもわかりやすいです。
ひとつ欲を言えば、テスト環境で作成した商品を簡単に本番モードに持って行きたいです。Webhookは「テストエンドポイントをインポート」する機能がありますが、商品ではできないのがちょっとだけ残念です。テスト環境で作成した商品をもう一度すべて本番モードで作り直す必要があります。
【※: 投稿してから知りましたが、実は商品詳細ページには「本番環境にコピー」というボタンがあります。テスト環境で作成したすべての商品を一気に本番環境までインポートするのはできないようですが、1つずつ商品をコピーするのは可能です。】
長年PayPalと戦っていたので、もっと早く乗り換えれば良かったと後悔しています(笑)
最後まで読んでくれてありがとうございました。何か質問やコメントがあれば、ぜひどうぞよろしくお願いいたします!