pinme-uniwebpay について
`@uniwebpay/sdk` を使い、PinMe Worker から UniwebPay に直接接続する方法を説明するガイドです。TypeScript で決済サービスを作る開発者向けに、決済フロー、サーバー側認証情報、Webhook 処理を扱います。
主な機能
- 01決済リンクとチェックアウトセッションの作成
- 02商品、価格、サブスクリプションの管理
- 03決済確認と返金処理
- 04Webhook 署名の検証
- 05自動注入されるサーバー側認証情報の利用
活用シーン
- 01注文の単発決済の受け付け
- 02定期サブスクリプションのチェックアウト設定
- 03Worker の Webhook で決済イベントを処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ glitternetwork/ pinme --skill pinme-uniwebpayインストール後、Claude Code で/pinme-uniwebpayを入力して読み込まれたか確認します。
詳細
PinMe UniwebPay 決済連携
@uniwebpay/sdk を使って UniwebPay を直接呼び出す、PinMe Worker(Cloudflare Worker TypeScript)での決済サービスの作成方法を説明します。
基本モデル:PinMe は プロジェクトごとではなく PinMe ユーザーごとに UniwebPay ウォレットとキーをプロビジョニングし、Worker のデプロイ時に UNIWEB_* 環境バインディングを注入します。Worker コードは SDK を使って UniwebPay を直接呼び出します。PinMe の決済プロキシルートは経由せず、旧 VibeCash API も呼び出してはいけません。
環境バインディングの契約
export interface Env {
UNIWEB_SECRET: string; // PinMe-provisioned sk_server_ key (server-side only)
UNIWEB_WEBHOOK_SECRET?: string; // wallet-level whsec_, used to verify webhook signatures
UNIWEB_API_URL?: string; // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)
UNIWEB_PAY_URL?: string; // UniwebPay checkout host override (default https://skill.uniwebpay.com)
UNIWEB_WALLET_ID?: string; // user-level wallet id (wal_), diagnostics/reconciliation only
WORKER_URL?: string; // this project's public URL: https://{projectName}.{platform api domain}
PROJECT_NAME?: string; // PinMe project name
DB?: D1Database; // project D1 (if enabled)
}
注入ルール(メタデータはデプロイ時に PinMe がサーバー側で再構築し、クライアントから指定されたバインディングは無視されます):
UNIWEB_*バインディングは、ユーザーの UniwebPay 認証情報がプロビジョニングされた後にのみ注入されます。新しく作成されたプロジェクトは自動的にプロビジョニングされ、すぐにバインディングを取得します。既存のプロジェクトでは、UniwebPay の有効化後またはキーのローテーション後に再デプロイし、新しいバインディングを取得する必要があります。WORKER_URL、PROJECT_NAME、API_KEY、DBなどの基本バインディングは、UniwebPay とは無関係に、すべてのデプロイで注入されます。- 同じ PinMe ユーザーが所有するすべてのプロジェクトで、1 つのウォレット、1 つの
sk_server_、1 つのwhsec_を共有します。 - PinMe が Worker に完全なウォレットシークレット(
sk_live_)を渡すことはありません。ユーザーに要求したり、コード、wrangler.toml、.dev.vars、レスポンス、ログ、D1、フロントエンドバンドルに記載したりしないでください。 - 実行時に
UNIWEB_SECRETが存在しない場合、ユーザーは UniwebPay を有効化していないか、再デプロイしていません。有効化して再デプロイするようユーザーに伝えてください。値を捏造してはいけません。
SDK クライアント
必ずサーバー側(Worker)でインスタンス化してください。ブラウザーで実行すると SDK は例外をスローします。
import Uniweb from "@uniwebpay/sdk";
function uniwebClient(env: Env): Uniweb {
return new Uniweb(env.UNIWEB_SECRET, {
baseUrl: env.UNIWEB_API_URL,
payUrl: env.UNIWEB_PAY_URL,
});
}
- コンストラクターの最初の位置引数はキーです(
sk_server_またはsk_live_のプレフィックスが必要)。2 番目の引数は省略可能なオプションです:{ baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }。 - SDK が自動で再試行するのは 429/5xx の GET/DELETE のみです。POST/PATCH は再試行されません(決済の重複を防ぐため)。
- Worker コードが
@uniwebpay/sdkをインポートする場合にのみインストールしてください。パッケージマネージャーは、プロジェクトに既存のロックファイルから選択してください。
連携方法の選択
| シナリオ | 方法 | 戻り値 |
|---|---|---|
| 固定金額の単発決済 | uniweb.links.create(...) | 恒久的に再利用可能な /p/ リンク(単発決済のみ) |
| 安定した商品カタログ | products.create + prices.create を一度実行し、priceId を保存 | 価格には恒久的な paymentUrl(/buy/ リンク)が付属します |
| 動的なカート/注文 | 価格を再利用または作成し、その後 uniweb.checkout.create(...) を呼び出す | session.url — 一度限りの利用で、有効期限は 24 時間です |
| サブスクリプション | 継続課金価格 + checkout.create({ mode: "subscription" }) または subscriptions.create | 上記と同じ |
| サーバー側での決済状況確認 | payments.get / list | サーバールートのみ |
金額は常に整数の最小通貨単位(セント)です。アプリに既存の強い慣例がない限り、デフォルトの通貨は SGD とします。ページビューごとに新しい商品/価格を作成しないでください。安定したカタログ項目を一度作成し、priceId を永続化してください。
決済方法と通貨のルール
| 方法 | 対応通貨 |
|---|---|
card | SGD、USD、EUR、GBP、JPY、CNY、HKD、AUD、MYR、THB(最小 10 最小通貨単位) |
wechat | SGD のみ |
alipay | SGD のみ |
paynow | SGD のみ |
- QR 決済方法(wechat/alipay/paynow)はすべて SGD のみ対応です。「WeChat/Alipay で CNY を使う」コードは決して生成しないでください。
- サブスクリプション(継続課金/
mode: "subscription")ではcardのみ使用できます。 paymentMethodTypesを省略すると、サーバーは通貨に応じた適切なデフォルトを選択します。明示的に指定する場合は、先に上記の表に照らしてユーザー入力を検証してください。
SDK サーフェスのクイックリファレンス
以下のサーフェスはソースコードに照らして検証済みです。すべてのパラメーターフィールドは camelCase(priceId、webhookUrl、startingAfter など)で、SDK がワイヤーレベルの変換を処理します。list() は { data: T[], hasMore: boolean } を返します。listAll() は products、prices、payments、customers、subscriptions、links で利用できる非同期ジェネレーターです(checkout と refunds では利用できません)。
Products(webhookUrl は製品ごとのコールバックの上書き設定です):
await uniweb.products.create({ name, description?, webhookUrl?, metadata? });
await uniweb.products.list({ limit?, startingAfter? });
await uniweb.products.get(productId);
await uniweb.products.update(productId, { name?, description?, webhookUrl?, active?, metadata? });
await uniweb.products.del(productId);
for await (const product of uniweb.products.listAll()) {}
Prices(返される価格には永続的な paymentUrl が設定されます。deactivate は販売を停止します):
await uniweb.prices.create({
productId,
amount, // integer minor units
currency, // e.g. "SGD"
type, // "one_time" | "recurring"
interval?, // "day" | "week" | "month" | "year"; recurring only
intervalCount?,
trialPeriodDays?,
metadata?,
});
await uniweb.prices.list({ productId?, limit?, startingAfter? });
await uniweb.prices.get(priceId);
await uniweb.prices.update(priceId, { active });
await uniweb.prices.activate(priceId);
await uniweb.prices.deactivate(priceId);
for await (const price of uniweb.prices.listAll({ productId? })) {}
Checkout sessions(webhookUrl は受け付けません。イベントは price → product → wallet のチェーンを通じて解決されます。URL は一度だけ使用でき、有効期限は 24 時間です):
await uniweb.checkout.create({
mode, // "payment" | "subscription"
lineItems: [{ priceId, quantity }],
successUrl?,
cancelUrl?,
customerEmail?,
customerId?,
trialPeriodDays?,
paymentMethodTypes?, // ["card", "wechat", "alipay", "paynow"]
metadata?,
});
await uniweb.checkout.list({ limit?, startingAfter? });
await uniweb.checkout.get(checkoutSessionId);
Payments(サーバー側でのステータス確認に使用します。金額、通貨、メタデータ、注文状態がすべて想定と一致する場合に限り、ローカルの注文を支払い済みにしてください):
await uniweb.payments.create({ amount, currency, customerId?, metadata? });
await uniweb.payments.list({ status?, customerId?, limit?, startingAfter? });
await uniweb.payments.get(paymentId, { gateway? });
await uniweb.payments.listRefunds(paymentId);
await uniweb.payments.sync(paymentId);
await uniweb.payments.void(paymentId);
for await (const payment of uniweb.payments.listAll({ status?, customerId? })) {}
Refunds(list/listAll はありません。payments.listRefunds を使用してください):
await uniweb.refunds.create({ paymentId, amount?, reason?, offlineRefundFlag? });
await uniweb.refunds.get(refundId, { gateway? });
Customers:
await uniweb.customers.create({ email, name?, metadata? });
await uniweb.customers.list({ email?, limit?, startingAfter? });
await uniweb.customers.get(customerId);
await uniweb.customers.update(customerId, { email?, name?, metadata? });
await uniweb.customers.del(customerId);
for await (const customer of uniweb.customers.listAll({ email? })) {}
Subscriptions(状態には trialing / active / past_due / unpaid / canceled があります。アクセス権の更新は、検証済みの Webhook または信頼できるサーバー側の照合ジョブからのみ行ってください):
await uniweb.subscriptions.create({ customerId, priceId, paymentMethodId?, trialPeriodDays?, metadata? });
await uniweb.subscriptions.list({ customerId?, status?, limit?, startingAfter? });
await uniweb.subscriptions.get(subscriptionId);
await uniweb.subscriptions.update(subscriptionId, { cancelAtPeriodEnd? });
await uniweb.subscriptions.cancel(subscriptionId); // cancel immediately
await uniweb.subscriptions.resume(subscriptionId); // undo cancelAtPeriodEnd
for await (const subscription of uniweb.subscriptions.listAll({ customerId?, status? })) {}
Payment links(永続的に再利用可能な /p/ リンクで、回収は一度だけです。webhookUrl はリンクごとのコールバックの上書き設定です):
await uniweb.links.create({
amount,
currency,
name?,
description?,
successUrl?,
cancelUrl?,
webhookUrl?,
paymentMethodTypes?,
metadata?,
});
await uniweb.links.list({ limit?, startingAfter? });
await uniweb.links.get(paymentLinkId);
await uniweb.links.update(paymentLinkId, { name?, description?, successUrl?, cancelUrl?, webhookUrl?, active? });
await uniweb.links.deactivate(paymentLinkId);
for await (const link of uniweb.links.listAll()) {}
Wallet および wallet レベルの Webhook 設定(危険:ユーザーのすべてのプロジェクトで共有されている wallet に影響します):
await uniweb.wallet.current();
await uniweb.wallet.update({ merchantName?, merchantCity?, merchantCountry?, webhookUrl? });
await uniweb.webhooks.set(url); // overwrites the wallet-level callback URL
await uniweb.webhooks.info();
await uniweb.webhooks.remove();
await uniweb.webhooks.rollSecret(); // rotates the shared whsec_
通常のプロジェクトルートから webhooks.set / remove / rollSecret や wallet.update を呼び出してはいけません。これらは、ユーザーのすべてのプロジェクトで共有される wallet のコールバックのフォールバックと署名シークレットを変更します。wallet の管理をユーザーが明示的に要求し、ルートにプロジェクト/管理者レベルの認可がある場合に限り、これらを生成してください。Refunds、subscription の変更、payouts、KYC、bank account API も同様です。ユーザーが該当する業務フローを明示的に要求し、コードに検証、永続化、認可がある場合に限り生成してください。
Webhook の統合
コールバック URL:リンクまたは製品に設定し、この Worker を指定します
イベント配信の優先順位:リンクごとの webhookUrl > 製品ごとの webhookUrl > wallet レベルのフォールバック。署名シークレットは常に wallet レベルの whsec_(つまり env.UNIWEB_WEBHOOK_SECRET)です。
PinMe は wallet に管理対象のフォールバックコールバック URL を設定しますが、これは署名シークレットを取得して保持するためだけに存在します。PinMe のサーバーはそこで受信したイベントを破棄し(204)、Worker に転送することはありません。したがって、業務イベントを処理するには、支払いを作成するリソースにこのプロジェクトの webhookUrl を明示的に設定する必要があります。
- Payment links:
links.createにwebhookUrlを渡します。 - Checkout sessions:
checkout.createにwebhookUrlフィールドはありません。イベントは価格の製品を経由するため、products.create(または再利用する製品)にwebhookUrlを設定します。
webhookUrl の構築ルール:
- コールバックのパスを単一の定数(例:
const WEBHOOK_PATH = "/api/pay/webhook")にまとめ、ルーターとwebhookUrlの構築で共有してください。これにより、パスの不一致によってコールバックが 404 になり、注文が保留状態のままになるのを防げます。 - ベース URL には
env.WORKER_URLを使用します:new URL(WEBHOOK_PATH, env.WORKER_URL).toString()。実行時に利用できる唯一の公開アドレス(プラットフォームのサブドメイン)です。ユーザーのカスタムドメインはenvにありません。request.urlよりもこちらを優先してください(現在のリクエストのホストがデプロイ先のアドレスとは限りません)。また、非 HTTP コンテキスト(cron/キュー)にはリクエストが存在しないため、URL がない場合は不正な URL を生成するのではなく、明示的にエラーにしてください。 - ローカル開発環境には
WORKER_URLがありません。request.urlをフォールバックとして使用すると localhost に解決されますが、UniwebPay からは到達できません。Webhook をローカルでテストするには、トンネル(cloudflared / ngrok)経由で Worker を公開してください。 webhookUrlは HTTPS である必要があります。URL のクエリーにシークレットや信頼性の基盤となるデータを含めないでください。orderIdなどの照合情報はmetadataに入れ、署名とmetadataを使って識別情報を検証してください(クエリー文字列は偽造できます)。
検証と処理
import { verifyWebhook } from "@uniwebpay/sdk";
const rawBody = await request.text(); // read the raw body exactly once
const event = await verifyWebhook( // note: async
rawBody,
request.headers.get("uniweb-Signature") || "", // format: t=<unix>,v1=<hex>
env.UNIWEB_WEBHOOK_SECRET,
);
verifyWebhook(rawBody, signature, secret)はPromise<WebhookEvent>を返します。署名のタイムスタンプ許容範囲は ±5 分で、失敗時には例外をスローします。- イベントの形式:
{ id: "evt_...", type, created, data: { object, productId?, priceId?, productName? } }。業務オブジェクトはevent.data.objectにあり、orderIdなどの関連付け情報はevent.data.object.metadataにあります。 - 実際に配信されることを確認済みのイベントタイプ:
payment.succeeded/payment.failed/payment.refunded/payment.partially_refunded、refund.succeeded/refund.failed/refund.abandoned、checkout.session.completed/checkout.session.expired、subscription.created/subscription.renewed/subscription.past_due/subscription.unpaid/subscription.trial_ending/subscription.canceled。
処理ルール:
- Webhook ルートでは、プロジェクト独自の認証をバイパスする必要があります。 UniwebPay のコールバックに含まれるのは
uniweb-Signature(およびuniweb-Event-Id/uniweb-Timestamp)のみで、プロジェクトのAPI_KEYは含まれません。グローバル認証ガードですべてのルートを保護している場合は、WEBHOOK_PATHを対象外にしてください。信頼の根拠は署名検証のみです。そうしないとコールバックが 401/403 となり、注文が処理されません。 - 署名が無効な場合は 400 を返します(UniwebPay に無意味な再試行を停止させるため)。一時的な処理エラーの場合は 500 を返します(再試行させるため)。
event.id(決済 ID / チェックアウトセッション ID / ローカル注文 ID と組み合わせて)を使って冪等性を確保します。- 付与処理の前に、金額、通貨、メタデータ、現在の注文状態を検証します。
- 10 秒以内に応答してください。配信時にリダイレクトは追跡されないため、Webhook ルートをリダイレクトの背後に配置しないでください。初回配信は即時です。失敗した場合は、5 分、30 分、2 時間、12 時間後に再試行され、最大 6 回の試行後にイベントが失敗としてマークされます。
- TypeScript では
UNIWEB_WEBHOOK_SECRETをオプションとして扱ってください。プロジェクトはシークレットの設定や再デプロイ前に存在する場合があります。実行時にシークレットがない場合は、ヒントを添えて 501 を返します。
セキュリティルール
- Uniweb のシークレット(
UNIWEB_SECRET、UNIWEB_WEBHOOK_SECRET)をレスポンス、ログ、メタデータ、テストスナップショット、D1、ソースコード、コミット済みのwrangler.toml/.dev.vars、ブラウザー側コードに含めてはいけません。ローカル開発では、コミット対象外の.dev.varsのみを使用してください。 - ブラウザー側コードで SDK をインポートしないでください。Cloudflare Workers では
process.envを使わず、env引数を使用してください。 - レガシーな VibeCash API や PinMe の決済プロキシルートを呼び出さないでください。UniwebPay による決済回収で
X-API-Keyを使って PinMe の決済 API を呼び出さないでください。Worker は SDK を通じて UniwebPay を直接呼び出します。 successUrl/cancelUrlは UX のためのリダイレクトにすぎません。ブラウザーのリダイレクトを根拠に処理を完了してはいけません。 アクセス権を付与するのは、検証済みの Webhook 処理後(または明示的な別のサーバー側検証後)のみにしてください。- SDK 呼び出しの前にユーザー入力を検証してください:金額(整数の最小通貨単位)、通貨(ISO 4217 に準拠し、決済手段の制約内であること)、数量、商品 ID / 価格 ID、決済手段のタイプ、注文の所有権、メタデータの形式。
永続化に関するガイダンス(D1)
プロジェクトですでに D1 を使用している場合、またはユーザーが永続化を求めている場合に限り、テーブルやマイグレーションを追加してください。注文フローでは、少なくともローカル注文 ID、対応する Uniweb リンク / セッション / 決済 / サブスクリプション ID、金額と通貨、ステータス、タイムスタンプ、処理済みの Webhook イベント ID(冪等性のため)を保存してください。シークレットは決して保存しないでください。
CREATE TABLE IF NOT EXISTS orders (
order_id TEXT PRIMARY KEY,
checkout_session_id TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
paid_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS payment_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
order_id TEXT,
raw_payload TEXT NOT NULL,
created_at INTEGER NOT NULL
);
Worker のリファレンス実装
import Uniweb, { verifyWebhook } from "@uniwebpay/sdk";
export interface Env {
UNIWEB_SECRET: string;
UNIWEB_WEBHOOK_SECRET?: string;
UNIWEB_API_URL?: string;
UNIWEB_PAY_URL?: string;
UNIWEB_WALLET_ID?: string;
WORKER_URL?: string;
PROJECT_NAME?: string;
DB?: D1Database;
}
type PaymentMethod = "card" | "wechat" | "alipay" | "paynow";
const VALID_PAYMENT_METHODS = new Set<PaymentMethod>(["card", "wechat", "alipay", "paynow"]);
const CARD_CURRENCIES = new Set(["SGD", "USD", "EUR", "GBP", "JPY", "CNY", "HKD", "AUD", "MYR", "THB"]);
// Single source of truth for the webhook path: shared by the router and the
// webhookUrl construction so the address sent to UniwebPay always matches the
// route the Worker actually serves.
const WEBHOOK_PATH = "/api/pay/webhook";
function uniwebClient(env: Env): Uniweb {
return new Uniweb(env.UNIWEB_SECRET, {
baseUrl: env.UNIWEB_API_URL,
payUrl: env.UNIWEB_PAY_URL,
});
}
function json(data: unknown, init: ResponseInit = {}): Response {
return Response.json(data, init);
}
function assertAmountCents(value: unknown): number {
const amount = Number(value);
if (!Number.isInteger(amount) || amount < 10) {
throw new Error("amountCents must be an integer minor-unit amount >= 10");
}
return amount;
}
function normalizeCurrency(value: unknown): string {
const currency = String(value || "SGD").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be an ISO 4217 code");
return currency;
}
// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies.
function defaultPaymentMethods(currency: string): PaymentMethod[] {
if (currency === "SGD") return ["card", "wechat", "alipay", "paynow"];
if (CARD_CURRENCIES.has(currency)) return ["card"];
throw new Error(`unsupported currency: ${currency}`);
}
function normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] {
const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency);
const methods = requested.map((m) => String(m).toLowerCase() as PaymentMethod);
for (const method of methods) {
if (!VALID_PAYMENT_METHODS.has(method)) {
throw new Error("paymentMethodTypes contains an unsupported method");
}
if (method !== "card" && currency !== "SGD") {
throw new Error(`${method} only supports SGD payments`);
}
if (method === "card" && !CARD_CURRENCIES.has(currency)) {
throw new Error(`card does not support ${currency}`);
}
}
return Array.from(new Set(methods));
}
// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the
// only public address available at runtime). request.url is only a fallback for
// older deploys; cron/queue contexts have no request, so WORKER_URL is required.
function projectWebhookUrl(env: Env, request?: Request): string {
const base = env.WORKER_URL || request?.url;
if (!base) throw new Error("WORKER_URL binding is missing");
return new URL(WEBHOOK_PATH, base).toString();
}
// ---- One-time collection: payment link ----
async function createPaymentLink(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });
let input: any;
try {
input = await request.json();
} catch {
return json({ error: "invalid JSON body" }, { status: 400 });
}
let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
try {
amount = assertAmountCents(input.amountCents);
currency = normalizeCurrency(input.currency);
paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
const orderId = input.orderId || crypto.randomUUID();
const uniweb = uniwebClient(env);
try {
const link = await uniweb.links.create({
amount,
currency,
name: input.name || "Payment",
description: input.description,
successUrl: input.successUrl,
cancelUrl: input.cancelUrl,
webhookUrl: projectWebhookUrl(env, request),
paymentMethodTypes,
metadata: { orderId, projectName: env.PROJECT_NAME },
});
return json({ orderId, linkId: link.id, url: link.url });
} catch {
return json({ error: "failed to create payment link" }, { status: 502 });
}
}
// ---- Dynamic orders: checkout session ----
// Note: checkout.create has no webhookUrl; the callback is set on the product.
// Stable catalog items should create the product/price once and persist the
// priceId — do not create new ones on every request.
async function createCheckoutSession(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });
let input: any;
try {
input = await request.json();
} catch {
return json({ error: "invalid JSON body" }, { status: 400 });
}
let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
try {
amount = assertAmountCents(input.amountCents);
currency = normalizeCurrency(input.currency);
paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
const orderId = input.orderId || crypto.randomUUID();
const quantity = Math.max(1, Math.floor(Number(input.quantity || 1)));
const uniweb = uniwebClient(env);
try {
const product = await uniweb.products.create({
name: input.productName,
webhookUrl: projectWebhookUrl(env, request),
metadata: { orderId, projectName: env.PROJECT_NAME },
});
const price = await uniweb.prices.create({
productId: product.id,
amount,
currency,
type: "one_time",
metadata: { orderId, projectName: env.PROJECT_NAME },
});
const session = await uniweb.checkout.create({
mode: "payment",
lineItems: [{ priceId: price.id, quantity }],
successUrl: input.successUrl,
cancelUrl: input.cancelUrl,
customerEmail: input.customerEmail,
paymentMethodTypes,
metadata: { orderId, projectName: env.PROJECT_NAME },
});
if (env.DB) {
await env.DB.prepare(
`INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at)
VALUES (?, ?, 'pending', ?, ?, ?)
ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`,
)
.bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000))
.run();
}
return json({ orderId, checkoutSessionId: session.id, url: session.url });
} catch {
return json({ error: "failed to create checkout session" }, { status: 502 });
}
}
// ---- Webhook handling ----
async function handleUniwebWebhook(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });
if (!env.UNIWEB_WEBHOOK_SECRET) {
return json({ error: "webhook verification is not configured" }, { status: 501 });
}
const rawBody = await request.text();
let event: Awaited<ReturnType<typeof verifyWebhook>>;
try {
event = await verifyWebhook(
rawBody,
request.headers.get("uniweb-Signature") || "",
env.UNIWEB_WEBHOOK_SECRET,
);
} catch {
return json({ error: "invalid signature" }, { status: 400 });
}
try {
if (env.DB) {
const object = event.data.object as { metadata?: Record<string, unknown> };
const orderId = String(object?.metadata?.orderId || "");
const now = Math.floor(Date.now() / 1000);
// event.id idempotency: duplicate deliveries become no-ops
await env.DB.prepare(
`INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING`,
)
.bind(event.id, event.type, orderId, rawBody, now)
.run();
if (event.type === "payment.succeeded" && orderId) {
// Production code should verify amount/currency/order state here before fulfilling
await env.DB.prepare(
`UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`,
)
.bind(now, now, orderId)
.run();
}
}
return json({ ok: true });
} catch {
// 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule
return json({ error: "processing error" }, { status: 500 });
}
}
// ---- Router ----
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// The webhook must come before any project auth guard: callbacks carry only
// uniweb-Signature, never the project API_KEY.
if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env);
// The project's own auth guard goes after this point, on the business routes.
if (url.pathname === "/api/pay/link") return createPaymentLink(request, env);
if (url.pathname === "/api/pay/checkout") return createCheckoutSession(request, env);
return json({ error: "not found" }, { status: 404 });
},
};
よくある間違い
| 間違い | 結果/修正方法 |
|---|---|
Webhook ルートがプロジェクトの API_KEY/ベアラー認証ガードによってブロックされている | コールバックが 401/403 を返し、注文が保留状態のままになります。WEBHOOK_PATH を認証対象から除外してください。信頼性の確認には署名検証のみを使用します |
リンク/商品に webhookUrl を設定し忘れる | イベントは PinMe の管理するフォールバックに送られ、破棄されます。Worker には届きません。ビジネスイベントを扱う場合は、リンク/商品ごとにこの Worker を指定する必要があります |
checkout.create に webhookUrl を渡す | このフィールドは存在しません。関連付ける商品に設定してください |
successUrl へのリダイレクトを基にフルフィルメントを実行する | 偽装可能です。検証済みの Webhook またはサーバー側の検証を基にのみフルフィルメントを実行してください |
コールバックホストをハードコードする、または request.url のみに依存する | env.WORKER_URL から構築し、request.url はフォールバックとしてのみ使用してください |
| CNY と wechat/alipay を組み合わせる | ソースコードでは QR 決済方式を SGD のみに制限しています。CNY で利用できるのはカード決済のみです |
| リクエストごとに新しい商品/価格を作成する | カタログ項目を一度だけ作成し、priceId を保存して再利用してください |
通常のビジネスフローで webhooks.set/remove/rollSecret または wallet.update を呼び出す | ユーザーのすべてのプロジェクトで共有されるウォレットのコールバックと whsec_ を変更/ローテーションします。管理者認証を伴う、ウォレット管理の明示的なリクエストでのみ生成してください |
| 金額に浮動小数点数の主要通貨単位を使用する | 常に整数の最小通貨単位を使用してください |
Worker で process.env を使う、またはブラウザーで SDK をインポートする | env 引数を使用してください。SDK はブラウザー環境を拒否します |
UNIWEB_SECRET/whsec_ を wrangler.toml、ソースコード、ログ、D1 に記載する | PinMe がデプロイ時に注入します。ローカルでは、コミット対象外の .dev.vars のみを使用してください |
| 検証前に本文を複数回読み取る、または JSON として読み取る | request.text() で一度だけ読み取り、生の文字列を verifyWebhook に渡してください |
最終チェックリスト
-
@uniwebpay/sdkは、Worker のコードがインポートする場合にのみインストールします。パッケージマネージャーはロックファイルに従います。 -
Envには、コードが使用する PinMe 注入バインディングが含まれています。UNIWEB_WEBHOOK_SECRET/WORKER_URLは任意のままとし、存在しない場合も適切に処理します。 - クライアントは
new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL })でインスタンス化します。 - VibeCash または PinMe の決済プロキシルートは使用しません。ソース、レスポンス、ログ、D1、テスト、ドキュメントにシークレットを含めません。
- 金額は整数の最小通貨単位であることを検証します。決済方法と通貨は制約テーブルに従います(QR 決済方式は SGD のみ)。
- イベントが必要な決済作成ではすべて、
env.WORKER_URLから構築したリンク/商品ごとのwebhookUrlを指定します。 - Webhook:生の本文を一度だけ読み取り、
verifyWebhookで検証し、400/500 を適切に使い分け、event.idで冪等性を確保し、ルートではプロジェクト認証を回避し、10 秒以内に応答します。 - フルフィルメントはブラウザーのリダイレクトに依存しません。アクセスを許可する前に、金額、通貨、メタデータ、注文状態を検証します。