たまにはStripe以外の決済手段も触ってみようということで、Pay.jpを触ってみました。今回はサブスクリプションをPay.jpで実装する方法について挑戦してみたので、どのように進めたかを紹介します。
サブスクリプションではv1モード
Pay.jpにはV1 / V2という2種類のAPIモードがありました。今回は V1を利用します。

ドキュメントによると、v2でのサブスクリプションは2026年中に対応予定とのことでした。そのため、現在はサブスクリプション作成に対応していないと判断しています。

ダッシュボードで事前準備
まずPay.jpのダッシュボードで処理系統を進めます。
APIキーを取得
まずはAPIキーです。以前のStripeと同様「本番・テスト」の2環境で「公開・非公開」の2種類がそれぞれ用意されています。

サブスクリプション商品を登録する
続いてサブスクリプション商品も登録しておきましょう。APIでも作れますが、手軽にダッシュボードで作っておきます。「課金日」がどうやら「毎月X日を請求日とする」のような設定みたいですね。ちなみに、今日の手順を手軽に進めたい場合は、「トライアル日数」を1以上にしておきましょう。

作成したあとは、プランIDを控えておきましょう。

Honoでアプリをセットアップ
今回はHonoを使います。まずアプリをセットアップしておきましょう。
% npm create hono
> sandbox@1.0.0 npx
> create-hono
create-hono version 0.4.0
✔ Target directory … practice-hono-payjp-subscription
✔ Which template do you want to use? › cloudflare-workers+vite
cloned honojs/starter#main to /Users/sandbox/practice-hono-payjp-subscription
✔ Copied project files
続いてwrangler.jsoncのnameを変更します。この後のnpm run cf-typegenが動かないため、なんでもいいので変えておきましょう。あと、payjpのSDKがBufferを使う様子でしたので、compatibility_flagsを追加しておきます。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "practice-hono-payjp-subscription",
"compatibility_date": "2025-08-03",
"compatibility_flags": ["nodejs_compat"],
"main": "./src/index.tsx"
}
続いて.dev.varsを用意します。ダッシュボードで取得した情報をここに保存しましょう。
PAYJP_PRICE_ID=pln_6054b24cf27fd74990a5e2c9c4e2
PAYJP_TEST_SECRET_API_KEY=sk_test_xxx
PAYJP_TEST_PUBLISHABLE_API_KEY=pk_test_xxx
続いて型定義ファイルを生成します。コマンドを使うことで、Envの値を型安全に使えます。
% npm run cf-typegen
> cf-typegen
> wrangler types --env-interface CloudflareBindings
⛅️ wrangler 4.65.0
───────────────────
Generating project types...
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
}
interface Env {
PAYJP_PRICE: string;
PAYJP_TEST_SECRET_API_KEY: string;
PAYJP_TEST_PUBLISHABLE_API_KEY: string;
}
}
interface CloudflareBindings extends Cloudflare.Env {}
Generating runtime types...
Runtime types generated.
────────────────────────────────────────────────────────────
✨ Types written to worker-configuration.d.ts
📖 Read about runtime types
https://developers.cloudflare.com/workers/languages/typescript/#generate-types
📣 Remember to rerun 'wrangler types' after you change your wrangler.jsonc file.
最後にsrc/index.tsxを更新して、生成した型情報をHonoに登録しましょう。
const app = new Hono<{ Bindings: CloudflareBindings }>()
Pay.jpをアプリに登録する
まずはnpm ライブラリを追加します。これを使ってサーバー側の処理を実装します。
npm i payjp
続いてsrc/renderer.tsxを編集し、クライアント側で使うPay.jsを追加しておきましょう。
import { jsxRenderer } from 'hono/jsx-renderer'
import { Link, ViteClient } from 'vite-ssr-components/hono'
export const renderer = jsxRenderer(({ children }) => {
return (
<html>
<head>
<ViteClient />
<Link href="/src/style.css" rel="stylesheet" />
<script src="https://js.pay.jp/v2/pay.js"></script>
</head>
<body>{children}</body>
</html>
)
})
最初のAPIを作る
準備ができたのでAPIから実装します。サブスクリプション作成の処理を簡単に実装しましょう。
app.post('/create-subscription', async (c) => {
const payjp = Payjp(c.env.PAYJP_TEST_SECRET_API_KEY
const customer = await payjp.customers.create({
email: 'test@example.com',
})
const subscription = await payjp.subscriptions.create({
customer: customer.id,
plan: c.env.PAYJP_PRICE_ID,
})
return c.json(subscription)
})
npm run devでアプリを起動します。その後curlでこのAPIを呼び出してみましょう。
% curl http://localhost:5173/create-subscription -XPOST | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 -100 639 100 639 0 0 704 0 -100 639 100 639 0 0 704 0 --:--:-- --:--:-- --:--:-- 704
{
"canceled_at": null,
"created": 1771139054,
"current_period_end": 1772348654,
"current_period_start": 1771139054,
"customer": "cus_8834a8e7d6371f447084eb04eedf",
"id": "sub_30117e73625cd35e0300f8d90fe7",
"livemode": false,
"metadata": {},
"next_cycle_plan": null,
"object": "subscription",
"paused_at": null,
"plan": {
"amount": 1000,
"billing_day": 1,
"created": 1771138079,
"currency": "jpy",
"id": "pln_6054b24cf27fd74990a5e2c9c4e2",
"interval": "month",
"livemode": false,
"metadata": {},
"name": "Hello Payjp Subscription",
"object": "plan",
"trial_days": 14
},
"prorate": false,
"resumed_at": null,
"start": 1771139054,
"status": "trial",
"trial_end": 1772348654,
"trial_start": 1771139054
}
これだけでサブスクリプションが作成できました。ダッシュボードを見ると、データが登録されています。

トライアルを設定していない場合、以下のようなエラーが出ます。要は支払い情報が登録されてないので初回決済ができないということですね。
body: {
error: {
code: 'doesnt_have_card',
message: 'Customer cus_6dc7700d73982c90b35b453d9cb1 has no active card.',
param: 'customer',
status: 400,
type: 'client_error'
}
}
決済フォームを組み込む
決済もできるようにするため、決済フォームを埋め込みましょう。今回は手軽に進めるため、dangerouslySetInnerHTMLを使って直接埋め込みます。実際の開発では、Hono Client ComponentやHonoXを使う方がいいかなと思いました。
app.get('/', (c) => {
return c.render(
<>
<h1>Hello Pay.jp Subscription!</h1>
<form id="payment-form">
<div id="v2-demo" class="payjs-outer">
</div>
<button type="submit">トークン作成</button>
</form>
<div id="subscription-result"></div>
<script dangerouslySetInnerHTML={{ __html: `
const payjp = window.Payjp("${c.env.PAYJP_TEST_PUBLISHABLE_API_KEY}");
const elements = payjp.elements();
const cardElement = elements.create("card");
cardElement.mount("#v2-demo");
const form = document.getElementById("payment-form");
form.addEventListener("submit", async function(event) {
event.preventDefault();
const token = await payjp.createToken(cardElement);
console.log(token);
});
` }} />
</>
)
})
DOMの読み込みが完了してから、CardElementをJSでマウントしています。あとはFormにSubmitイベントを設定して、送信後のトークン化処理を実装しました。
アプリのUIはこのようになります。見た目の調整はCSSでやることになりそうですね。

フォームと連携したサブスクリプションを作成する
最後にフォーム送信からサブスクリプションを作れるようにしましょう。まずはAPI側でtoken_idを受け取れるようにします。c.req.jsonを使ってリクエストBodyからトークンのIDを取りましょう。
app.post('/create-subscription', async (c) => {
const { token_id } = await c.req.json<{ token_id: string }>()
const payjp = Payjp(c.env.PAYJP_TEST_SECRET_API_KEY)
const customer = await payjp.customers.create({
email: 'test@example.com',
card: token_id,
})
const subscription = await payjp.subscriptions.create({
customer: customer.id,
plan: c.env.PAYJP_PRICE_ID,
})
return c.json(subscription)
})
あとは先ほど作成したトークン化処理部分で、先ほどのAPIにトークンIDを送信するようにします。
form.addEventListener("submit", async function(event) {
event.preventDefault();
const token = await payjp.createToken(cardElement);
const response = await fetch("/create-subscription", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token_id: token.id }),
});
const data = await response.json();
const resultEl = document.getElementById("subscription-result");
if (response.ok) {
resultEl.textContent = JSON.stringify(data, null, 2);
} else {
resultEl.textContent = "Error: " + (data?.message || response.status);
}
});
これで連携完了です。フォームを送信すると、作成されたサブスクリプションの情報が表示されます。

ダッシュボードにも表示されています。

先ほどとは異なり、Customerデータにカード情報も登録されていました。

完成したリポジトリはこちら
ここまでの実装を試したコードは、以下のリポジトリで公開しています。

Stripeとの違い
ざっと触ってみて感じた違いとしては、以下の6点でしょうか。
- 1つのサブスクリプションに、1つのプランのみ作れる
- 請求サイクルをplan側で設定できる
- タイムゾーンが日本時間
- APIパラメーターの数が少ない(payjpは5つ、Stripeは孫要素を省略しても30個以上)
- 従量課金系はない
- 埋め込みカードフォームのデフォルトデザインがない
シンプルに作れる良さと、柔軟なカスタマイズ性で比較することになりそうかなぁと思います。
