crypto-payments について
暗号資産決済を実装する開発者向けの、プロバイダー非依存のスキルです。請求とコールバックを扱い、決済状態の例外、安全で冪等な Webhook 処理、照合、返金や AML 保留への対策を説明します。
主な機能
- 01プロバイダーの状態と最終状態を対応付け
- 02Webhook 署名を安全に検証
- 03重複コールバックを冪等に処理
- 04決済・照合データを記録
- 05返金と AML 保留を安全に扱う
活用シーン
- 01一部入金に対応する暗号資産決済を実装
- 02Webhook 再送による二重付与を防止
- 03決済記録を使って入金状況を調査
- 04返金や AML 保留をレビューに回す
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ ssheleg/ sheleg-dev --skill crypto-paymentsインストール後、Claude Code で/crypto-paymentsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/callback-route-hardening.mdheleket-provider.mdtesting-and-local-dev.md詳細
暗号資産決済
カード決済では、請求は成功するか失敗するかで、金額はそのまま金額です。一方、暗号資産決済は、一部だけ支払われたり、請求額を超えて支払われたり、支払いが遅れたり、価格変動後に支払われたりする可能性があるリクエストです。暗号資産のチェックアウトで発生する不具合のほとんどは、カード決済を前提としたコードを書き、本番環境でこうしたケースに遭遇することで生じます。
このスキルはプロバイダーに依存しません。特定のゲートウェイに関する具体的なリクエスト/レスポンス形式は references/heleket-provider.md に記載されています。以下の不変条件は、Coinbase Commerce、NOWPayments、BTCPay、Heleket など、請求書を発行してコールバックを送信するあらゆるサービスに当てはまります。プロバイダーに依存しないとは限らない項目が1つあり、該当箇所で明示しています。それは、ゲートウェイごとに異なる Webhook 署名のアルゴリズムです。
プロバイダーの選択は、技術的な判断ではなく、事業およびコンプライアンス上の判断です。 暗号資産決済プロセッサーの規制上の位置付けは、ライセンス、AML プログラム、制裁リスクなどの点で大きく異なり、また変化します。顧客からの資金をプロセッサー経由で受け取る前に、そのプロセッサーの現状を確認し、定期的に再確認してください。このスキルでは、プロバイダーの正しい統合方法を説明しますが、どのプロバイダーを信頼すべきかは示しません。
目次
- ライフサイクルと資金が行方不明になる場所
- ステータスのマッピングと最終性
- Webhook 署名の検証
- Webhook 処理の冪等性
- コールバックルートの強化
- 換算バッファー
- 照合フィールド
- 入金処理:金額の優先順位
- 返金と AML 保留
- テスト用認証情報と本番用認証情報の境界
- ローカル開発とテストマトリックス
- セキュリティチェックリスト
- よくある落とし穴
ライフサイクルと資金が行方不明になる場所
your app gateway blockchain
│ create invoice ─────►│
│◄──── invoice id, address, amount, expiry
│ │
│ (user pays) ────────┼──────────────────────►│
│ │◄── confirmations ─────│
│◄──── webhook: status change (MAY REPEAT)
│ credit the user │
│◄──── webhook: status change (again, later)
設計上のあらゆる判断に影響する3つの事実:
- コールバックは少なくとも1回配信されます。 プロバイダーは 200 を受け取るまで再試行します。配信のたびに入金処理を行うハンドラーでは、3回分の入金が発生します。
- 支払額は請求額と同じとは限りません。 不足払い、過払い、そして「見積もりとは異なる通貨での支払い」は、エラーではなく通常の状態です。
- ステータスは、想定するような単調な変化をするとは限りません。 支払いは
pending → paid → refund_process → refund_paidと進む場合もあれば、AML による保留が何日も続く場合もあります。終端状態の集合を明示的にモデル化してください。
自サイトへのリダイレクトから、利用権限を決して付与しないでください。 ユーザーのブラウザーが /success に戻ったことから分かるのは、ユーザーがブラウザーを使用しているということだけです。入金処理は Webhook、またはサーバー側のステータスポーリングに基づいて行い、クライアント側のランディングページに基づいて行ってはいけません。
ステータスのマッピングと最終性
ゲートウェイごとに用語は異なります。各ゲートウェイのステータスを自分の状態に、一度、1つの関数でマッピングし、終端状態の集合を明示的に定義してください。
const FINAL_STATUSES = ['PAID', 'FAILED', 'REFUNDED', 'EXPIRED'] as const;
function mapStatus(providerStatus: string): PaymentStatus {
switch (providerStatus) {
case 'paid':
case 'paid_over': return 'PAID'; // over-payment still pays
case 'wrong_amount': return 'UNDERPAID'; // partial — DO NOT credit in full
case 'confirm_check': return 'CONFIRMING'; // seen, not yet confirmed
case 'cancel':
case 'fail':
case 'system_fail': return 'FAILED';
case 'refund_process': return 'REFUNDING';
case 'refund_paid': return 'REFUNDED';
case 'locked': return 'AML_HOLD'; // compliance review, may take days
default: return 'PENDING';
}
}
paid_over は、どちらの方向でも間違った扱いをされがちです。失敗として扱うと支払い済みの顧客を失い、支払額と請求額が一致したものとして扱うと超過分を知らないうちに提供してしまいます。請求額ではなく、受取額を入金処理してください。詳しくは以下の優先順位を参照してください。
Webhook の署名検証
コールバックは、そうでないことが証明されるまでは認証されていない公開エンドポイントです。
以下の sign() は、あるプロバイダー(Heleket)の方式です:
md5(base64(json) + apiKey)。 パターン(定数時間比較、生のボディへの署名、パース前の検証)は応用できますが、アルゴリズムは応用できません。2026-08-30 に確認したところ、Coinbase Commerce は生のボディに対する HMAC-SHA256(X-CC-Webhook-Signature)、NOWPayments はキーでソートした JSON に対する HMAC-SHA512(x-nowpayments-sig)、BTCPay は HMAC-SHA256(BTCPay-Sig)で署名します。アルゴリズムは各プロバイダーの公式リファレンスを確認してください。Heleket の完全な形式については、references/heleket-provider.md を参照してください。
import { createHash, timingSafeEqual } from 'node:crypto';
// Heleket's scheme. Swap the digest for your provider — see the note above.
function sign(payload: unknown, apiKey: string): string {
const json = JSON.stringify(payload);
return createHash('md5')
.update(Buffer.from(json).toString('base64') + apiKey)
.digest('hex');
}
function verify(body: Record<string, unknown>, apiKey: string): boolean {
const { sign: given, ...rest } = body; // signature is never part of the signed payload
if (typeof given !== 'string') return false;
const expected = sign(rest, apiKey);
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(given, 'utf8');
return a.length === b.length && timingSafeEqual(a, b); // length check FIRST — timingSafeEqual throws on mismatch
}
実際の連携で問題になった、次の 3 つのルールを守ってください。
-
定数時間で比較してください。 署名を
===で比較すると、1 バイトずつ漏えいします。また、先に長さを確認してください。timingSafeEqualはバッファーの長さが異なると 例外をスローする ため、単純に呼び出すと、偽造された署名に対して 403 ではなく 500 を返してしまいます。 -
プロバイダーが署名したものとまったく同じものに署名してください。 パース済みのボディを再シリアライズすると、内容が変わる可能性があります。フレームワークが対応している場合は、生のボディを保持してください。
-
エスケープに注意してください。 ゲートウェイによっては(特に PHP ベースのもの)、
json_encodeで生成された JSON に署名し、スラッシュを\/、非 ASCII 文字を\uXXXXとしてエスケープします。JSON.stringifyはどちらも行いません。URL を含まないペイロードでは署名が一致するのに、URL を含むペイロードでは一致しない場合は、これが原因です。両方のエンコード方式を試し、どちらか一方を受け入れてください。 そうしないと、正当なコールバックを拒否してしまいます。const candidates = [json, json.replace(/\//g, '\\/')]; return candidates.some((c) => timingSafeCompare(md5(b64(c) + key), given));
ドメインに合わせて何かをパースする前に署名を検証し、失敗時は詳細を含めずに 403 を返してください。詳細なエラーは情報を推測する手がかりになります。
冪等な Webhook 処理
読み取ってから書き込むのは避けてください。データベースに 2 回目の配信を拒否させます。
次の 3 つは別々に扱います。支払いのライフサイクル状態、確定済みの決済によって付与される不変の権利、そして返金や保留です。これらを混同すると、決済されていない金額を付与してしまいます。CAS(status notIn FINAL_STATUSES ガードを使う updateMany)で進めるのはライフサイクルだけです。PAID の場合と同じく、FAILED への遷移でも count: 1 が返されるため、クレジット付与の許可にはなりません。クレジットを付与するのは、確定済みの決済(mapped === 'PAID')に対して一度だけとし、請求書ごとの UNIQUE な権利付与台帳行とクレジット付与を原子的に行ってください。返金と保留はそれぞれ独自の処理経路を通し、重複として処理を打ち切らないでください。 実装例のハンドラーは references/callback-route-hardening.md にあります。
重複の場合は必ず 200 を返してください。 409 を返すと、プロバイダーが延々と再試行します。
コールバックルートの強化
リクエストに関することが 2 つあります。どちらも「ロードバランサーに移行したら Webhook が動かなくなった」という問題の一般的な原因です。1 つは、x-forwarded-for[0] ではなく、実際に管理しているプロキシホップから呼び出し元の IP を取得することです。もう 1 つは、CSRF の除外対象をプレフィックスではなく完全一致でそのコールバックパスだけにすることです。/api/payments のプレフィックスで除外すると、チェックアウトエンドポイントまで除外されてしまいます。そこが金銭のやり取りを行う場所です。
コードを含む両方の説明は、references/callback-route-hardening.md を参照してください。
上記の署名検証が実際の防御です。これらは多層防御としての対策です。
変換バッファー
USD で見積もります。ユーザーは、見積もりから送金までの間にレートが変動するコインで支払います。マージンがなければ、わずか数%のレート変動で支払い全額が wrong_amount になり、必要もないのに一部入金の処理をすることになります。
const BUFFER = 0.01; // 1%
const invoiceAmount = round(amountUsd * (1 + BUFFER), 2);
ルールは2つです。
- 請求額にはバッファーを加え、購入意図分を付与します。 バッファーを加えた金額を請求し、ユーザーが購入しようとした分を付与します。バッファーはレート変動を吸収するためのもので、収益ではありません。ユーザーに購入内容を伝える際に、バッファー分を含めてはいけません。
- 両方の数値を保存します。
amountUsd(購入意図額)とinvoicedAmount(請求額)は異なります。サポートへの問い合わせには、両方なければ回答できません。
バッファーはこのドキュメントではなく、受け付けるコインの実際のレート変動を観測して決めてください。
照合フィールド
6か月後に、「この支払いは実際に着金したのか、手数料を差し引いていくら受け取ったのか」と聞かれることがあります。答えられるだけの情報をウェブフックの受信時点で保存してください。決済ゲートウェイの保持期間は、あなたの保持期間ではありません。
| フィールド | 理由 |
|---|---|
merchantAmount | ゲートウェイの手数料を差し引いた後の受取額です。請求額と一致することはありません |
paidAmount + payerCurrency | 実際の着金額と、その通貨を示します |
paidAmountUsd | 決済確定時にゲートウェイが独自に算出したUSD評価額です |
commission | これがあれば、手取り額と総額の差を計算で求められ、記録を掘り返す必要がありません |
txid | チェーン上の取引を特定する唯一のリンクです。これがなければ異議申し立てを解決できません |
network | TRC20 と ERC20 と BEP20 は、同じコインでもチェーンが異なり、手数料も異なります |
from | 支払元アドレスです。いずれ対応を求められる AML に関する問い合わせに必要です |
決済確定を知らせるウェブフックで、ステータスを変更するのと同じ更新内にこれらを記録してください。
クレジット付与:金額の適用順序
4つの次元があり、暗黙に混同してはいけません。金額(通貨+最小単位。浮動小数点数は使いません)、アセット(ネットワーク+トークン+小数単位の金額)、権利(プランの単位)、そして日付付きFXレート(レート+ソース+時刻)です。paidAmountUsd と amountUsd は金額、tokenAmount はアセットの金額(請求額の基準額+バッファーであり、プランの単位ではありません)です。次元をまたぐ ?? は暗黙の変換です。まず各候補をUSDの最小単位に換算してください。アセットの金額を換算できるのは、信頼できるソースの日付付きレートがある場合だけです。レートが不明または欠落している場合は、推測せずに換算をブロックしてください。
const usd = [payment.paidAmountUsd // Money: gateway's valuation
, toUsdMinor(payment.tokenAmount, quote) // Asset -> Money, dated quote or BLOCKED
, payment.amountUsd]; // Money: the intent — last resort
const credit = usd.find(v => v != null);
適用順序が重要です。過払い分を購入意図額で評価すると、超過分を気付かれないまま保持することになります。未払い分を購入意図額で評価すると、商品を余分に渡すことになります。ソースと使用したレートの両方を明記した監査記録を残してください。そうしなければ、最初に残高への異議を申し立てられた際に、正当性を証明できません。
返金と AML による保留
暗号資産にはチャージバックがありませんが、それを「取り消しがない」と誤解する人がいます。
- 返金は複数ステップからなる状態であり、単発のイベントではありません。
refund_process→refund_paid|refund_fail。返金を依頼しただけでは、ユーザーへの返金は完了していません。 - AML による保留には期限がありません。
lockedの支払いは数時間で解決することもあれば、解決しないこともあります。「決済プロバイダーによる審査中」と正直に伝え、クレジットを付与せず、自動キャンセルもせず、請求を再試行しないでください。保留中に2つ目の請求を発行すると、支払いが2件発生して商品が1つだけという事態につながります。 - ウェブフックから返金を自動実行してはいけません。 保留と返金は、人間が確認できるキューに回してください。
そして、文章で注意を促すのではなく、実行前提条件にしてください。 上記の3行は4回のリリースにわたって助言として書かれていましたが、それを読まないエージェントや、誰も確認しないシェルには、どの助言も効きませんでした。このパックには、PreToolUse ゲート(plugins/sheleg-dev/hooks/money-gate.js)が同梱されるようになりました。このゲートは、セッション中に権限を持つ担当者が該当カテゴリを承認するまで、返金、支払い、異議申し立ての終了、test を宣言した実行での HELEKET_API_KEY のエクスポート、本番環境でのライブ sk_live_… キーと SKIP_BILLING=true の併用を拒否します。承認対象のカテゴリと承認方法は、このパックの README.md に記載されています。アプリケーションでは、同じルールをキューと担当者で運用し、エージェントのシェルではフックで適用します。
テスト用/本番用の認証情報の境界
設計を始める前に、プロバイダーが実際に提供しているものを確認してください。 カード決済代行業者は、互いに独立した2つの環境を用意しています。Stripe はまったく別のアカウントを提供し、キー自体に環境を示す接頭辞(sk_test_/sk_live_)を付けます。ほとんどの暗号資産ゲートウェイはそうではありません。プロバイダーのドキュメントをもとに、次の3つに答えて記録してください。
- テスト用の認証情報は別にありますか? 「テストモード」ではなく、別のシークレットがあるかどうかです。
- キーから環境を判別できますか? 接頭辞やスコープなど、何でもかまいません。
- キーはウェブフックの署名にも使われますか? 使われる場合、権限を絞り込めません。コールバックの検証に使えるものは、請求を作成することもできます。
回答によって設計が決まります。そして、最悪の組み合わせはよくあります。キーは1つで識別の目印はなく、「テストモード」はマーチャントアカウントの切り替えとして提供されます。つまり、誰かがダッシュボードの設定を切り替えるまでは同じシークレットでテスト用の請求書を作成し、切り替え後は本番の請求書を作成します。デプロイも、キーを持つマシンへの通知もありません。
プロバイダーが許す範囲で、次の順に最も強力な制御を構築してください。
-
別のサンドボックスアカウントを使います。 こうすれば、開発マシン上の認証情報から本番環境にアクセスできません。なぜその制御があるのかを全員が忘れた後でも機能する唯一の制御です。README の警告よりもこちらを優先する理由は、まさにそこにあります。
-
別の認証情報がない場合は、環境を明示して、起動時にアサーションを行います。 宣言はシークレットとは別の変数に保持します(
<PROVIDER>_ENV=test|production)。キーを保持し、その環境も指定する変数は、自分自身との照合しかできません。変数が2つあれば照合できます。シークレットではない情報(マーチャント ID、または本番キーの短縮ハッシュなど)で本番の識別情報を固定し、起動時に次の両方の不一致を拒否します。- 本番用認証情報をテスト用として宣言している場合――明らかな損失です。
- テスト用認証情報を本番用として宣言している場合――見落とされやすいケースです。請求書の決済先は誰も照合しないマーチャントになり、収益が不足するまで何もエラーになりません。
決済ハンドラーではなく、モジュールの読み込み時にアサーションを行ってください。キーを保持しているだけの実行も失敗させる必要があります。実際に決済を行わずにキーを保持するのは、エージェントや CI の実行です。また、判別できない場合、つまり何も固定されていない場合は、テスト側で拒否してください。「安全だと証明できなかった」が「安全だった」と解釈されてはなりません。
-
残るリスクを記録します。 プロバイダーがテスト用認証情報を提供していない場合、開発者は本番用認証情報を保持することになり、アサーションでその危険にさらされる範囲は狭められても、なくすことはできません。避けられない露出を明示した場合と、黙って放置した場合は別物です。前者はローテーション、監視、見直しが行われますが、後者はインシデントによって再発見されます。
以下のモック経路にも同じルールが適用されます。本番環境での SKIP_BILLING=true は近道ではなく、無料で利用できる経路です。アサーションによってこれを拒否します。
テスト用認証情報がまったくない1つのゲートウェイについて、最初から最後まで実例を示します――references/heleket-provider.md の テスト用/本番用の境界 と 残るリスク を参照してください。
ローカル開発とテストマトリックス
両方用意したい開発経路――本番環境では使えない SKIP_BILLING=true 分岐と、実際のゲートウェイをトンネル経由で使い、プロバイダーに要求しても発生させられない異常系には署名付きのモックコールバックを使う方法――に加え、テストスイートの成功を信頼する前に網羅すべき10のケースと、それぞれが検出する不具合について説明しています。
references/testing-and-local-dev.md。
セキュリティチェックリスト
- ドメイン解析の前に署名を検証する。定時間比較を行い、先に長さを確認する
- 2種類のJSONエスケープをどちらも受け入れるか、生のボディに署名する
- Webhook は、最終状態ではないステータスに対する比較交換によって冪等にする
- 重複には200を返し、決して409を返さない
- IP許可リストでは、プロキシのホップ数を正しく数える。ヘッダーがない場合に許可しない
- CSRFの対象外にするパスは1つだけとし、完全一致で判定する
- クライアント側のリダイレクトを根拠に利用権を付与しない
- APIキーと加盟店IDは環境変数から取得し、リポジトリには決して含めない
- プロバイダーがサンドボックスアカウントを提供している場合はそれを使用する。使用できない場合は、その理由を記録する
- 宣言された環境は秘密情報とは別の変数とし、起動時のアサーションで両方の不一致を拒否する
- アサーションはモジュールの読み込み時に実行し、キーを保持するだけの実行でも失敗させる
- ローカルトンネル以外のすべての環境で、コールバックURLにHTTPSを使用する
- 本番環境では
SKIP_BILLINGが無効であることをアサーションで確認する - 決済確定時に照合用フィールドを書き込む
- 返金とAML保留は人間に回し、Webhookから自動処理しない
よくある落とし穴
| 症状 | 原因 |
|---|---|
| 一部のペイロードでは署名が有効だが、ほかでは無効になる | スラッシュ/Unicodeのエスケープ方法の違い |
| 偽造されたコールバックで500エラーが発生する | 長さの異なるバッファに対するtimingSafeEqualが例外を投げる |
| ユーザーに2回または3回クレジットが付与される | 比較交換ではなく、読み取り後に書き込んでいる |
| プロバイダーがいつまでも再試行する | 重複に409を返している |
| インフラ変更後にWebhookが動作しなくなった | x-forwarded-forのホップ数の数え方 |
| 製品がないのに「支払い済み」のユーザーがいる | ブラウザーのリダイレクトを根拠にクレジットを付与した |
支払いの半分がwrong_amountになる | 換算バッファがない |
| 「純収入はいくらか」に答えられない | 手数料とmerchantAmountを保存していない |
| 開発マシン、CIジョブ、またはエージェントの実行が本番キーを保持している | 認証情報が分離されておらず、キーを宣言された環境に結び付けるアサーションもない |
| 誰も確認していない加盟店ダッシュボードに実際の請求書がある | ステージング用の認証情報を本番環境として宣言していた。不一致を拒否するものがなかった |
縮退時の動作
- Claude Code ではない場合(Cursor、Codex、skills CLI、APIコンテナ):前述の
PreToolUseゲートであるplugins/sheleg-dev/hooks/money-gate.jsは実行されません。支払い、返金、test実行時のエクスポート済みHELEKET_API_KEY、本番環境でのSKIP_BILLING=trueを拒否するものは何もありません。この3行は再び単なる助言になり、4回のリリースにわたって効果がなかったのもまさにこの状態が原因です。利用している実行環境を明示し、認可は人間の手順で行うことをはっきりさせてください。 - プラグインではなくコピーしてインストールした場合:ドクトリンは適用されますが、フックは適用されません。READMEにはカテゴリと、セッションで認可する方法が記載されています。
- nodeがない、またはフィクスチャを実行できない場合:不変条件のフィクスチャは、このスキルの実行可能な部分です。不変条件が満たされたと報告するのではなく、チェックが実行されなかったと伝えてください。実行されなかったチェックと合格したチェックは、誰も確認しなかった終了コードからは区別できません。
トリガー
ここに振り分けられるその他のフレーズ:「crypto checkout」、「pay with crypto」、「TRC20」、「IPN」、 「Heleket」、「NOWPayments」、「недоплата」。
0.16.2の説明から移動したフレーズ:「USDT payment」、「приём криптоплатежей」。