最近、決済処理が必要でStripeを使い始めました!
受託開発多めなのであんまり決済周りの処理はやってこなかったんですが、利用する機会があったので一通り触ってみました。便利ですねー!
弊社、地図屋なので一般Webというよりは特殊なデータ処理とかWebGL/Three.jsを触りがちなんですが、最近はモダンなSvelteKitとHonoをめちゃ推していってるので、今回はこれらを利用して以下の課題に取り込んでみようと思います!
- SvelteKitにHonoを乗せる構成でフロントとバックエンドを組む
- Stripeの埋め込みチェックアウトを作成する
- StripeのWebhookイベントの受け取り
イベントを受け取った後はDBの操作などなど行うと思いますが、この記事ではそこまで踏み込みません!
事前準備
まず、SvelteKitでプロジェクト作成していきましょう!
今回はTypeScriptを使っていきます。
❯ npx sv create svelte-stripe-sample
Need to install the following packages:
sv@0.6.6
Ok to proceed? (y) y
┌ Welcome to the Svelte CLI! (v0.6.6)
│
◇ Which template would you like?
│ SvelteKit minimal
│
◇ Add type checking with Typescript?
│ Yes, using Typescript syntax
│
◆ Project created
│
◇ What would you like to add to your project? (use arrow keys / space bar)
│ prettier, eslint, vitest
│
◇ Which package manager do you want to install dependencies with?
│ npm
│
◆ Successfully setup add-ons
│
◇ Installing dependencies with npm...
◆ Successfully installed dependencies
│
◇ Successfully formatted modified files
必要なパッケージのインストールしていきます。
npm i stripe @stripe/stripe-js hono zod @hono/zod-validator
SvelteKitではSSRを利用することができ、尚且つ**/api
のルートからAPIを配信することができます。
さらに、このルートでHonoを動かす**とかもできちゃいます。
なので今回はこれを利用していきましょう!
まずは、api
ディレクトリ以下に+server.ts
を作成し、リクエストをapp(あとで作成するHonoアプリケーション)に流していきます。
ちなみに、[...paths]
のようなフォルダを作成すると、/api/hogehoge/fugafuga
にマッチするようになります。
src/routes/api/[...paths]/+server.ts
import app from "$lib/api/server";
import type { RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = ({ request }) => app.fetch(request);
export const POST: RequestHandler = ({ request }) => app.fetch(request);
Honoのアプリケーションはこんな感じで作成します。
フォルダ構成やファイル名は適当ですが、とにかくサーバーでのみ動作するように、.server.ts
と命名してあげます。
src/lib/api/app/index.server.ts
import { Hono } from "hono";
import stripe from "./stripe.server";
const health = new Hono().get("/", async (c) => {
return c.text("OK!");
});
export default api;
サーバーを起動させてhttp://localhost:5173/apiに接続するとOK!
と帰ってきます。
npm run dev
超簡単にSvelteKit上でHonoが動作しました!
Stripeで商品設定など
Stripeではダッシュボードからサンドボックス環境を立ち上げて色々実験できるので、この環境を利用しました。
適当に商品を作成してみましょう。
Stripeの商品登録の詳細などはちょっと端折りますが、今回は500円/月と300円/月のサブスクリプションを用意してみました。
適宜ダッシュボードから設定してください!
(今回は利用しませんでしたが)サブスクのプランをユーザーが自由に変更できるようにするには、設定画面から「ユーザーのサブスク変更」を許可しておく必要があります。
APIキーなどはトップページに記載があるので、.env
に設定しておきましょう。
Stripe CLIのセットアップ
Stripeは便利なCLIツールが用意されているので、インストールしていきましょう!
以下はmacOSでのコマンドですが、他のOSでも調べればすぐに出てくると思います。
brew install stripe/stripe-cli/stripe
以下のコマンドを入力するとブラウザが立ち上がり、パスコードなど入力するとcliでログインできます。
stripe login
CLIを使ってみましょう!
アプリケーションでStripeを利用する際には、lookup_keyを設定し、lookup_keyを検索することで動的に最新の価格などを取得することができるようです。
-
商品(product)とは
- 顧客に提供する特定の商品やサービス
- スタンダードプランとプロフェッショナルプランがあるなら、別の商品
-
価格(price)とは
- 商品の定期購入や一回限りの購入の両方について請求する価格
- 月額・年額・1回限りなどは、1つの商品で、価格が異なる感じ
デフォルトではlookup_keyが存在しないので、CLIを利用して付与していきましょう!
-
lookup_key
- 静的な文字列で、nullable
- 動的に価格を取得するために利用される参照キー
- アカウントでユニークらしい
まずはダッシュボードから商品のIDを取得しておきます。
今回ならprod_RKzfg6gMWnjo4H
になりますね!
このIDを利用してCLIで作成した商品(Product)の情報を取得します!
❯ stripe products retrieve prod_RKzfg6gMWnjo4H
{
"id": "prod_RKzfg6gMWnjo4H",
"object": "product",
"active": true,
"attributes": [],
"created": 1733322977,
"default_price": "price_1QSJgbGdlPm03DeKRmLmmKbO",
"description": null,
"images": [],
"livemode": false,
"marketing_features": [],
"metadata": {},
"name": "のこのこスタンダード",
"package_dimensions": null,
"shippable": null,
"statement_descriptor": null,
"tax_code": null,
"type": "service",
"unit_label": null,
"updated": 1733322977,
"url": null
}
default_price
に商品のデフォルト価格のIDが書かれているので、価格を取得してみます。
❯ stripe prices retrieve price_1QSJgbGdlPm03DeKRmLmmKbO
{
"id": "price_1QSJgbGdlPm03DeKRmLmmKbO",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1733322977,
"currency": "jpy",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"product": "prod_RKzfg6gMWnjo4H",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 300,
"unit_amount_decimal": "300"
}
この価格IDを利用して、価格オブジェクトにlookup_keyを付与してみましょう!
❯ stripe prices update price_1QSJgbGdlPm03DeKRmLmmKbO \
-d "lookup_key"="nokonoko_std_per_month"
{
"id": "price_1QSJgbGdlPm03DeKRmLmmKbO",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1733322977,
"currency": "jpy",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": "nokonoko_std_per_month",
"metadata": {},
"nickname": null,
"product": "prod_RKzfg6gMWnjo4H",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 300,
"unit_amount_decimal": "300"
}
付与されましたね!もう一個の商品も同じことをしておきましょう!
チェックアウトの実装
こんな感じのStripe組み込み決済画面を「チェックアウト」と呼ぶらしいです。
アプリケーションのドメインから遷移せずに、画面に埋め込むタイプのチェックアウトもあるので、これを実装していきましょう!
まずは一旦サブスク決済のフローを確認してみました。
ではやっていきましょう!
先ほども少し言及しましたが、まずはリポジトリのルートに.env
を作成しておきましょう。
PUBLIC_STRIPE_KEY=
SECRET_STRIPE_KEY=
STRIPE_WEBHOOK_SECRET=
PUBLIC_STRIPE_KEY
とSECRET_STRIPE_KEY
はダッシュボードのこれを設定できます。
STRIPE_WEBHOOK_SECRET
はWebhookの章で後述します。
クライアント側のStripeのインスタンスを作ります。
src/lib/stripe/client.ts
import { PUBLIC_STRIPE_KEY } from "$env/static/public";
import { loadStripe } from "@stripe/stripe-js";
async function createStripeClient() {
return await loadStripe(PUBLIC_STRIPE_KEY);
}
const stripeClient = await createStripeClient();
export default stripeClient;
次に、サーバー側のStripeのインスタンスを作ります。
ややこしいですが@stripe/stripe-js
がクライアント用のライブラリで、stripe
がサーバー用のライブラリです。
src/lib/api/app/stripe.server.ts
import { env } from "$env/dynamic/private";
import Stripe from "stripe";
export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");
HonoはRPCを利用し、フロントエンドとバックエンドでAPIの仕様を共有しながら開発することができます。
(TypeScriptで表も裏も書いちゃうメリットですね!)
まず、サーバー側は普通にHonoのインスタンスを作成し、export type AppType = typeof app;
型情報を出力します。
src/lib/api/server.ts
import { Hono } from "hono";
import api from "./app/index.server";
const app = new Hono().route("/api", api);
export default app;
export type AppType = typeof app;
クライアントはそれを利用してhcを返す関数を書くことで、フロントエンドから利用することが出来るようになります!
src/lib/api/client.ts
import { hc } from "hono/client";
import type { AppType } from "./server";
export function makeApiClient(fetch: typeof global.fetch) {
return hc<AppType>("/api", { fetch });
}
今回はさほど有効活用できるわけでじゃないので、RPCの話はここまで。
次に、商品を選択すると、埋め込みのチェックアウトが出てくる画面を作ります。
今回は商品情報は直接ソースコードに書いてしまいました。
<script lang="ts">
import { makeApiClient } from "$lib/api/client";
import stripeClient from "$lib/stripe/client";
import "@sveltejs/kit";
type product = {
id: string;
title: string;
lookup_key: string;
};
const products: product[] = [
{
id: "prod_RKzfg6gMWnjo4H",
title: "のこのこスタンダード",
lookup_key: "nokonoko_std_per_month",
},
{
id: "prod_RKzf45ctmQfvLd",
title: "のこのこスペシャル",
lookup_key: "nokonoko_special_per_month",
},
];
let checkout: any;
let isProcessing = false;
function closeOutside(event: any) {
if (event.target != event.currentTarget) {
return;
}
close();
}
function close() {
history.back();
}
const handleCancel = async () => {
if (checkout) {
await checkout.destroy();
}
if (isProcessing) {
isProcessing = false;
}
close();
};
const startCheckout = async (product: product) => {
isProcessing = true;
const client = makeApiClient(fetch);
try {
const response = await client.stripe.checkout.$post({
json: product,
});
const resJson = await response.json();
const clientSecret = resJson.clientSecret;
if (clientSecret) {
if (checkout) {
await checkout.destroy();
}
checkout = await stripeClient?.initEmbeddedCheckout({ clientSecret });
checkout.mount("#checkout");
} else {
isProcessing = false;
}
} catch (err) {
if (checkout) {
await checkout.destroy();
isProcessing = false;
}
}
};
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
{#each products as product}
<div on:click={closeOutside}>
<h3>{product.title}を購入しますか?</h3>
{#if isProcessing}
<button on:click={handleCancel}> キャンセル </button>
{:else}
<button on:click={() => startCheckout(product)}> 購入する </button>
{/if}
</div>
{/each}
<div id="checkout"></div>
チェックアウトが完了した後のページを作ります。今回はsuccess!
と表示されるだけの画面にします。
src/routes/success/+page.svelte
success!
フロントエンドからリクエストがあったタイミングでチェックアウトのためのをclient_secret
返すAPIを作成します。
フロントエンドからlookup_keyを受け取り、StripeのSDKを利用して商品の価格IDを取得します。
その後、sessionを作成してフロントエンドに返します。
チェックアウトには「どのユーザーがサブスクリプションをしたのか」わかるようにcustomerIdが必要ですが、今回はダミーのemailと適当なidを用意し、チェックアウトすることにしました。
src/lib/api/app/stripe.server.ts
import { env } from "$env/dynamic/private";
import { Hono } from "hono";
import Stripe from "stripe";
export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");
const app = new Hono();
app.post("/checkout", async (c) => {
const { lookup_key } = await c.req.json();
const url = new URL(c.req.url);
const protocol = url.protocol;
const host = url.host;
const prices = await stripe.prices.list({
lookup_keys: [lookup_key],
});
const priceId = prices.data[0].id;
const email = "nokonoko@example.com";
let customerId = "dummy_user";
try {
await stripe.customers.retrieve(customerId);
} catch {
const customer = await stripe.customers.create({
email,
});
customerId = customer.id;
}
try {
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
customer: customerId,
payment_method_types: ["card"],
client_reference_id: "dummy_user",
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
return_url: `${protocol}//${host}/success?session_id={CHECKOUT_SESSION_ID}`,
});
return c.json({ clientSecret: session.client_secret! });
} catch (err) {
console.error(err);
return c.json({ message: "error" });
}
});
export default app;
http://localhost:5173 に接続してみましょう!
「のこのこスタンダード」の「購入する」ボタンを押すと300円の決済画面が出てきて、もう一方だと設定した通り500円の画面が出てくると思います!
すげー!!!
Webhookをイベントを受け取る
チェックアウトからサブスクリプションを申し込むと、作成したsuccess画面に遷移します。
このタイミングでStripe本体には「〇〇さんが〇〇プランの決済が完了」といった情報が格納されるため、入力したクレジットカードなどから毎月決済されます。
が、これだけだとただ課金されるだけで自分らで作成したアプリケーション側には何の情報も反映されませんね。
StripeではWebhookを設定することができるため、/api/stripe/webhook
新たなエンドポイントを作成しStripeに設定してあげましょう。
こうすることで「決済完了」や「プラン変更」「解約」などさまざまなイベントを受け取り、サービス内のDBを更新する、といったことが可能となります。
ではエンドポイントを作成します。
src/lib/api/app/stripe.server.ts
import { env } from "$env/dynamic/private";
import { Hono } from "hono";
import Stripe from "stripe";
export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");
const app = new Hono();
app.post("/checkout", async (c) => {
const { lookup_key } = await c.req.json();
const url = new URL(c.req.url);
const protocol = url.protocol;
const host = url.host;
const prices = await stripe.prices.list({
lookup_keys: [lookup_key],
});
const priceId = prices.data[0].id;
const email = "nokonoko@example.com";
let customerId = "dummy_user";
try {
await stripe.customers.retrieve(customerId);
} catch {
const customer = await stripe.customers.create({
email,
});
customerId = customer.id;
}
try {
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
customer: customerId,
payment_method_types: ["card"],
client_reference_id: "dummy_user",
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
return_url: `${protocol}//${host}/success?session_id={CHECKOUT_SESSION_ID}`,
});
return c.json({ clientSecret: session.client_secret! });
} catch (err) {
console.error(err);
return c.json({ message: "error" });
}
});
// ここから追加!
app.post("/webhook", async (c) => {
const sig = c.req.header("stripe-signature");
const body = await c.req.text();
if (!sig) {
return c.json({ message: "" }, { status: 400 });
}
try {
// `/webhook`は誰でもリクエストできるので、stripeからのリクエストであることを検証する
const event = stripe.webhooks.constructEvent(
body,
sig,
env.STRIPE_WEBHOOK_SECRET,
);
switch (event.type) {
case "payment_intent.created": {
console.log(event.data.object);
break;
}
default:
break;
}
return c.json({ message: "" }, { status: 200 });
} catch (err) {
const errorMessage = `Webhook signature verification failed. ${
err instanceof Error ? err.message : "Internal server error"
}`;
console.log(errorMessage);
return c.json({ message: "" }, { status: 400 });
}
});
export default app;
今回はAPIの個数が2つなので実装しませんでしたが、普通は認証・認可の機能などを持つ認証ミドルウェアを実装すると思います。
ただ、/webhook
のエンドポイントは外部(Stripe)からリクエストが飛んでくるので認証処理を挟むことができません。
なので、Stripeから送信される「生のリクエスト」を設定されたシークレットを利用して検証することで、Stripeからの正規のリクエストであることを保証します。
(ちなみに、「生のリクエスト」が重要らしくOpenAPIHono
は利用できなさそうでした。)
本番環境ではStripeのダッシュボードからWebhook受け取りエンドポイントのURLを設定し、尚且つシークレットも取得してきますが、ローカル環境でも開発することができます。
Stripe CLIを利用して、以下のようにlocalhostのエンドポイントをフォワードするように設定しましょう。
whsec_
から始まる文字列がシークレットなのでSTRIPE_WEBHOOK_SECRET
に設定してあげましょう。
% stripe listen --forward-to http://localhost:5173/api/stripe/webhook
> Ready! You are using Stripe API Version [2024-11-20.acacia]. Your webhook signing secret is whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx (^C to quit)
この状態でstripe listen
が起動したまま、もう一度サブスクするなり、Stripeでの操作を行ってみてください。
もしくは、別なターミナルを開いて、以下のようにコマンドを実行してみましょう!
% stripe trigger checkout.session.completed
すると ターミナルにこんな画面が表示されているはずですね!
2024-12-05 12:26:20 --> invoice.payment_succeeded [evt_1QSVhoGdlPm03DeKPMG23y7C]
2024-12-05 12:26:20 <-- [200] POST http://localhost:5173/api/stripe/webhook [evt_1QSVhoGdlPm03DeKPMG23y7C]
2024-12-05 12:36:41 --> customer.created [evt_1QSVrpGdlPm03DeKvxAhZFZj]
2024-12-05 12:36:41 <-- [200] POST http://localhost:5173/api/stripe/webhook [evt_1QSVrpGdlPm03DeKvxAhZFZj]
ローカルホストでもWebhookのイベント受け取れました!
eventの種類が受け取れるので、イベントに応じてDBを更新し、課金プランを切り替えるなどすればOKです!
まとめ
ということでタイトルにある通り「SvelteKitとHonoを使って、Stripeの埋め込みチェックアウトとWebhookイベントの受け取る!」というところまでやっていきました!
「SvelteKitでHonoを利用する」という話と「Stripeを利用してチェックアウトを機能させる」と「StripeのWebhookイベントをHonoで受け取る」という3つの話をしてしまいましたが、それぞれとても簡単なのでプロダクトにも簡単に取り込めそうだなーと感じました!
これからも諸々使い倒していきたいです!