暗号資産決済を受け付ける
PaysellはTONネットワーク上でTONとUSDTの決済を処理します。請求書(インボイス)を作成すると、決済用のリンクをお渡しし、オンチェーンで着金が確認されて残高に反映されると署名付きのコールバックが届きます。
概要#
Paysellができること、できないこと。
Paysellは決済処理業者であり、ウォレットではありません。秘密鍵を扱ったり、ブロックチェーンを監視したり、トランザクションがいつ確定するかを判断したりする必要は一切ありません — それは私たちが引き受けます。
インボイスごとに専用の受取アドレスが割り当てられます。購入者が支払うと、ネットワークによる転送の確認を待ち、手数料を差し引いた残りをあなたの残高に反映します。出金先は任意のアドレスを選べます。
決済の流れ#
6つのステップ、そのほとんどは私たちが担当します。
6つのステップ、そのほとんどは私たちが担当します。
- 1
顧客が支払いボタンを押す
あなたのサーバーが金額と独自の注文参照番号を添えて当社APIを呼び出します。
- 2
アドレスを発行する
事前に生成されたプールから新しい受取アドレスが取得され、このインボイスに紐付けられます。1つのアドレスは常に1つの未決済インボイスにのみ属します — これにより決済がインボイスに紐付けられます。
- 3
顧客がコインを送金する
QRコードをスキャンするか、アドレスをコピーします。返却された
payment_urlに誘導すれば、金額・アドレス・QR・カウントダウン・リアルタイムステータスまで、ページはすべて用意済みです。 - 4
転送を検知する
ブロックチェーンデータの独立した2つのソースを照会し、その結果を比較します。両者が一致しない場合、都合の良い方を選ぶのではなく処理を停止します。
- 5
ファイナリティを待つ
マスターチェーンへの取り込みに加えてさらに3ブロック。おおよそ15秒 — 一度確定したように見えて後で消えてしまう決済は、あなたの損失になるため、そのリスクは取りません。
- 6
残高に反映され、通知される
手数料が差し引かれ、残りがあなたの残高に反映され、
order_idを含む署名付きWebhookがあなたのサーバーに送られます。
決済からコールバックまでおよそ1分:ネットワーク確認に約15秒、残りは当社が監視対象アドレスを巡回する処理です。
お金の流れ#
手数料と、その計算対象。
手数料は0.2%で、あなたのショップが登録された時点で固定されます。後で標準料率が変わっても、あなたの料率は変わりません — 設定への参照としてではなく、各インボイスに数値として記録されます。
手数料は実際に入金された額から計算され、インボイスで請求した額からではありません。5 USDTを請求して20 USDTが届いた場合、手数料は20の方から計算されます。不足払いの場合は、実際に届いた額から計算されます。
Invoice: 5.000000 USDT
Received: 20.000000 USDT (the buyer sent more)
Fee 0.2%: 0.040000 USDT (on 20, not on 5)
Credited: 19.960000 USDT過剰入金は全額が残高に反映されます — 差額を当社が保持することはありません。不足入金の場合、購入者が同じアドレスへ追加送金できるようインボイスは開いたままになります。
クイックスタート#
最初のインボイスまで5分。
5つのステップ。2つはアカウント画面でのクリック、1つはあなたのサーバーからの1回のリクエスト、残りの2つは自動的に進みます。
- 1
ショップを作成する
アカウント画面で作成します。審査待ちなしですぐに決済受付を開始できます。本人確認はバックグラウンドで静かに行われ、出金のみを制限し、入金には影響しません。
- 2
APIキーを発行する
ショップ → APIキー → 新規キー。キーとWebhookシークレットは一度だけ表示され、二度と表示されません。データベースのパスワードと同じように保管し、決してブラウザに送らないでください。
- 3
インボイスを作成する
あなたのサーバーからのリクエスト1回で、リンクが1つ返ってきます。以下の4つのスニペットは、すべてまったく同じ内容を送っています。
- 4
購入者を
payment_urlへ送るこれが決済のすべてです — 金額、アドレス、QRコード、カウントダウン、リアルタイムのステータス — 構築するものは何もありません。購入者が実際に目にするものは決済ページを参照してください。
- 5
Webhookを待つ
チェーン上で入金が確定し残高に反映されると、署名付きの
payment.creditedイベントをあなたのサーバーへPOSTします。署名を検証し、それから注文を支払い済みにしてください — ただしdata.statusがpaidまたはoverpaidのときだけです。Webhookを参照してください。
The same request, four ways
curl -X POST https://paysell.me/api/merchant/v1/invoices \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset": "USDT_TON",
"amount": "5",
"order_id": "order-1042",
"idempotency_key": "order-1042"
}'レスポンス内のpayment_urlに購入者をリダイレクトしてください。これで完了です — 残りはWebhookとして届きます。
What to do next
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
認証#
APIキーと、その使い方。
すべてのリクエストはAuthorizationヘッダーにあなたのキーを含めます。
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAここで発行されるキーはすべて sk_live_ で始まります。sk_test_ プレフィックスはテストネットに向けたデプロイにのみ存在し、そのようなデプロイは提供していません — テストを参照してください。当社が保管するのはキー自体ではなく一方向ハッシュです。そのため、私たちを含め誰もそれを再度あなたに表示することはできません。紛失した場合は、新しいキーを発行して古いものを失効させてください。
ショップはキーから導出されるため、どのリクエストにもショップIDを渡す必要はありません。1つのキーは自身のショップに対してのみ操作できます。
アドレスにはバージョンが入ります: /api/merchant/v1/…。同じバージョンの中では項目を追加するだけで、名前を変えたり、黙って意味を変えたりはしません。あなたのコードが壊れるような変更には新しい接頭辞 /v2 を用意し、/v1 は予告した期間そのまま動き続けます。
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
インボイスの作成#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesリクエストボディ
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| asset | string | はい | TONまたはUSDT_TONのいずれか。 |
| amount | string | はい | コインの通常単位を文字列で: "5" は 5 USDT です。小数はコインの持つ桁数まで。金額を参照。 |
| order_id | string | いいえ | 独自の参照番号。200文字まで。すべてのWebhookで返送されます — これにより決済を注文に紐付けます。 |
| description | string | いいえ | 1000文字まで。決済ページで購入者に表示されます。 |
| ttl_minutes | number | いいえ | インボイスが支払い可能な状態を維持する時間(分)。1〜1440。省略するとデフォルトが適用され、現在は2時間です。 |
| idempotency_key | string | いいえ | 200文字まで。リトライ時に同じ値を送ると、2件目ではなく同じインボイスが返されます。これはボディのフィールドであり、Idempotency-Key ヘッダーではありません — ここではそのヘッダーは読まれません。 |
レスポンス · 201
{
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
"address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
"asset": "USDT_TON",
"amount": "5",
"amount_minor": "5000000",
"status": "pending",
"paid": "0",
"paid_minor": "0",
"order_id": "order-1042",
"description": "Pro subscription",
"expires_at": "2026-09-06T17:20:55Z",
"created_at": "2026-09-06T15:20:55Z"
}注文へのマッピング
| フィールド | 使い方 |
|---|---|
| invoice_id | 注文に紐付けて保存してください。決済を識別する際に他のあらゆる場所で使われます。 |
| payment_url | 購入者をここにリダイレクトしてください。それ以外に構築するものはありません。 |
| address | 自前の決済画面を描画する場合のみ使用します。返された通り正確に表示してください — 下記の警告を参照。 |
| amount | 通常単位の金額。送ったとおりの値です。表示にはこちらを使います。 |
| amount_minor | 同じ金額を最小単位の整数にしたもの。計算にはこちらを使います。 |
| expires_at | カウントダウンを表示してください。経過後、このインボイス用のアドレス監視は停止します。 |
| status | ここでは常にpendingです。実際の変化はWebhookで届きます。 |
UQ…、テストネットでは0Q…)。変換したり、見た目を整えたり、同じアドレスの別のエンコーディングに置き換えたりすると、未デプロイのウォレットに送られたコインは送信者に返送されてしまいます。インボイスの取得#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}上記と同じ形式で、status、paid、paid_minor が現在の状態を反映します: paid は通常単位でどれだけ届いたか、paid_minor は同じ額を最小単位の整数で表したものです。Webhookを取りこぼした場合のフォールバックや、サンキューページで役立ちます。
ポーリングは数秒に1回程度に留め、Webhookを主要な経路として扱ってください。他のショップに属するインボイスは403ではなく404を返すため、IDの存在確認に悪用されることはありません。
インボイスのキャンセル#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelまだ開いているインボイス — pending または underpaid — をクローズし、そのアドレスを解放します。顧客が決済を放棄した場合に使用してください — アドレスは有限のリソースであり、返却することでプールの健全性が保たれます。
すでに開いていないインボイスは 409 を返します。underpaid のインボイスをキャンセルしても、誰かにコインが返るわけではありません: すでに反映されたお金はあなたの残高に残り、閉じられるのは追加入金の受け付けだけです。
Webhook#
何が届くか、どう検証するか。
キーの作成時にWebhookのURLを設定します。入金が反映されたとき、そして追加確認に回った入金が却下されたときに、そのURLへPOSTします。どの配信にも署名が付き、あなたが2xxを返すまで約1日半にわたって再試行を続けます。商品の引き渡しは status: paid または overpaid を見て行ってください — 呼び出しが届いたこと自体を根拠にしないでください。
イベント
| イベント | いつ | ボディの内容 |
|---|---|---|
| payment.credited | 送金がチェーン上で確定し、当社の手数料が差し引かれ、残りが残高に入っています。 | 下記に挙げるフィールド。 |
| payment.rejected | 追加確認のため保留されていた入金(ステータスリファレンスを参照)が却下されました。その資金は残高に入りません。 | invoice_id、order_id、asset、amount、tx_hash、reason。商品は引き渡さないでください。先行する送金でインボイスがすでに paid になっていた場合、このイベントは余分な入金についてのものであり、その支払いについてではありません。 |
届く内容
{
"event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
"type": "payment.credited",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"credited": "4905000",
"fee": "95000",
"status": "paid",
"paid_minor": "5000000",
"tx_hash": "97a1f0…"
}
}{
"event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "payment.rejected",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"tx_hash": "97a1f0…",
"reason": "could not be matched to any order"
}
}フィールドマッピング
| フィールド | 意味 |
|---|---|
| event_id | イベントごとに一意で、X-Paysell-Event-Id ヘッダーにも入っています。保存し、重複は無視してください — 下記参照。 |
| data.order_id | あなたの参照番号。これで注文を検索してください。 |
| data.amount | この送金で購入者が送った額。最小単位です — 通常単位を受け取るAPIとは逆になります。 |
| data.fee | 当社が差し引いた額。最小単位です。 |
| data.credited | 残高に入った額: amount − fee。最小単位です。 |
| data.paid_minor | このインボイスにこれまでに届いた合計額。最小単位です。underpaid のときに効いてくるフィールドで、ステータスは届いた額が足りないことを告げ、こちらはどれだけ足りないのかを告げます。 |
| data.asset | 実際に届いたコイン。インボイスが求めたコインとは限りません。 |
| data.asset_mismatch | 届いたコインがインボイスのコインと異なる場合にのみ、値 true で現れます。お金はあなたに反映されますが、インボイスは未払いのままで、status が paid になることはありません。 |
| data.invoice_asset | asset_mismatch と一緒に届きます: インボイスが実際に求めているコインです。 |
| data.status | 現時点でのインボイスのステータス: pending、underpaid、paid、overpaid、expired のいずれか。期待した値と比較してください。 |
| data.tx_hash | オンチェーンのトランザクション。記録およびサポート用。 |
毎回の配信に付くヘッダー
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| ヘッダー | 意味 |
|---|---|
| X-Paysell-Event | イベントの種類: payment.credited または payment.rejected。 |
| X-Paysell-Event-Id | イベントごとに一意です。重複排除にはこの値を使ってください。 |
| X-Paysell-Timestamp | 当社が署名した時刻(unix秒)。署名対象の文字列の一部です。 |
| X-Paysell-Signature | sha256= に続けて16進のHMAC。下記を参照してください。 |
署名の検証
すべてのリクエストは、キーの作成時に一度だけ表示されたWebhookシークレットで署名されます。署名は HMAC-SHA256(secret, "{timestamp}.{raw_body}") です — X-Paysell-Timestamp のタイムスタンプ、リテラルのドット、そしてボディのバイト列。処理を行う前に検証してください: これを怠ると、あなたのURLを知った誰でも「支払い済み」の注文を渡せてしまいます。
Python:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".").update(body) // body is the raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}署名の対象は受信したままの生のボディバイト列です。JSONをパースして再シリアライズするとバイト列が変わり — キーの順序、空白 — 署名は一致しなくなります。比較は定数時間で行ってください(hmac.compare_digest、crypto.timingSafeEqual): 単純な == は最初のバイトが違うほど早く返り、その差だけで署名を1バイトずつ推測できてしまいます。
タイムスタンプの許容範囲
自分の時計から5分を超えて離れたタイムスタンプは、前後どちらの方向でも拒否してください。タイムスタンプが署名対象の文字列に含まれているのは、まさに署名を壊さずには書き換えられないようにするためです。それを実際の防御に変えるのが、この許容範囲です。これがないと、一度捕捉されたリクエストは永久に有効なままで、いつでもリプレイできてしまいます — 署名そのものは期限切れになりません。サーバーの時計はNTPで合わせておいてください。さもないと、この検査が正常な配信を拒否し始めます。
重複
同じイベントが複数回届くことがあります。これはバグではありません: あなたが2xxを返すまで再試行しますし、処理は成功したのに応答が当社に届かなかった配信は再送されます。X-Paysell-Event-Id(ボディにも event_id として入っています)を記録し、2回目の到着では何も起こらないようにしてください。
再試行
1回目の送信は入金が反映され次第すぐに行われます。失敗した場合 — タイムアウト、接続拒否、TLSエラー、リダイレクト、2xx以外のステータス — は、固定のスケジュールで再試行します:
1分 → 5分 → 15分 → 1時間 → 6時間 → 24時間合計7回、およそ31時間にわたって分散します。序盤の間隔が短いのは、よくある原因が再起動中だった受信側で、すでに復旧していることが多いからです。終盤が疎なのは、1日ダウンしているサーバーを叩き続けても誰の得にもならないからです。
最後の試行のあと、その配信は dropped とマークされ、当社側では自動的に送信を止めます。失われるわけではありません: アカウント画面の決済行に状態、試行回数、エラーの種別が表示され、再送信ボタンで7回の試行を最初からやり直せます。もう一つの手段は GET /api/merchant/v1/invoices/{invoice_id} です — インボイスは常に自身のステータスを知っています。
WebhookのURLが満たすべき条件
URLは保存時に検査され、さらに配信のたびに毎回検査されます。検査に通らないURLは、保存時には 422 と code: "webhook_url_rejected" で返され、あとから通らなくなった場合はその配信を failed とマークします — 再試行はありません。ルールは次のとおりです:
- `https://` のみ、かつポート443。Webhookは決済情報を運びます。平文のhttpでは経路上の誰にでも読めてしまいます。
- IPアドレスではなくドメイン名。 いずれにせよ証明書は必要ですし、裸のIPに証明書は発行されません。
- `localhost` は不可、
.local、.internal、.corp、.lan、.testの名前も不可です — 当社のサーバーはあなたのネットワークに到達できませんし、当社側で解決してしまう名前こそ、絶対に呼び出してはならない相手です。 - URLに認証情報を入れないでください(
https://user:pass@…)。トークンが必要なら、自前のものをパスかクエリパラメータに入れてください。 - その名前が解決するすべてのアドレスが公開アドレスであること — AとAAAAの両方です。プライベート、ループバック、リンクローカル、CGNATの範囲は拒否されます。しかもこの検査は配信のたびに繰り返されるので、あとからレコードを
127.0.0.1に向け直しても通りません。 - リダイレクトは経由ではなく失敗です。 当社は追跡しません: 検査したのはあなたが指定したアドレスであって、
Locationヘッダーのアドレスは検査していません。
速やかに応答する
10秒以内であれば、どの2xxでも構いません — 接続を含めて、それが当社のタイムアウトのすべてです。まず応答し、遅い処理はその後に行ってください。自分のデータベースを待ってから返すエンドポイントは、いずれタイムアウトとして記録されて再試行され、あなたは同じイベントを2回処理することになります。それ以外 — 4xx、5xx、リダイレクト、ハング — はすべて失敗した試行として数えられ、上記のスケジュールに戻されます。
配信について、正直なところ
保証されるのは配信の仕組みです: およそ31時間にわたる7回の試行、アカウント画面からの手動再送、そして常に本当のステータスを知っているインボイスのエンドポイント。Webhookが一度も届かなくても損をしないようにフローを組んでください — サンキューページでインボイスを読むか、1時間に1回、未決済のインボイスを一括で照合してください。Webhookは速い経路であって、唯一の経路ではありません。
A complete receiver#
Signature, deduplication and a fast answer, end to end.
The snippets above verify one signature. This is the whole endpoint: raw body, signature check, deduplication by event_id, a fast 2xx, and the one condition that is allowed to mark an order paid.
Node.js with Express. express.raw is the part people get wrong: express.json() hands you a parsed object, and bytes you re-serialise from it are not the bytes we signed.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
const sentAt = Number(timestamp)
if (!Number.isFinite(sentAt)) return false
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // ±5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + ".").update(body) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
ステータスリファレンス#
すべてのインボイス・決済ステータスの解説。
インボイス
| ステータス | 意味 | 対応 |
|---|---|---|
| pending | 支払い待ち。 | 注文をオープンのままにしてください。 |
| paid | 全額支払い済み。 | 商品を引き渡してください。 |
| overpaid | 請求額より多く届きました。超過分は全額あなたに反映されます。 | 商品を引き渡してください。希望すれば差額を返金してください。 |
| underpaid | 請求額より少なく届きました。インボイスは開いたままで、アドレスも維持されます: 購入者は同じ場所へ追加入金でき、paid_minor がすでに届いている額を示します。残りの有効期間に加えて、expires_at から24時間の猶予期間のあいだ支払い可能です。 | 追加入金を待つか、顧客と話し合って解決してください。商品は引き渡さないでください — このインボイスは未払いです。 |
| expired | 猶予期間も含めて期間が終了しました。お金が入っている場合があります: 届いた分はあなたの残高に残っており、paid_minor がその額を示します。 | 新しいインボイスを提示してください。古いアドレスへの支払いは受け付けないでください: インボイスが期限切れになるとアドレスはプールに戻され、大幅に遅れて届いた送金は自動反映ではなくサポート対応の案件になります。何も受け取っていないと顧客に伝える前に paid_minor を確認してください。 |
| cancelled | あなたによってキャンセルされました。アドレスはプールに返却されます。 | 何もする必要はありません。 |
決済
アカウント画面で確認できます。決済途中の顧客サポート時に役立ちます。
| ステータス | 意味 |
|---|---|
| detected | チェーン上で検知、確認待ち。 |
| confirmed | ネットワークが確認済み。次は残高への反映です。 |
| credited | 残高に反映済み。この時点でWebhookが発火します。 |
| review | 追加確認のため保留 — 例えば未決済インボイスのないアドレスにコインが届いた場合など。 |
| rejected | 反映されなかった。理由が記録されます。 |
決済が `review` になったとき
一部の入金は、すぐに反映せず追加の確認のために保留されます: 異常に大きな金額、未決済のインボイスがないアドレスへのコインの到着、あるいは当社が参照している2つのブロックチェーン情報源が食い違っている場合です。失われるものはありません — 資金は判断が下るまで待機し、判断が出しだいWebhookが発火します。それは数分後のことも数時間後のこともあります。review と表示されている決済でコールバックが来ていないのは、障害ではなく通常のことだと考えてください。注文に関わる場合は、tx_hash を添えてサポートにお問い合わせください。
金額#
送るときは通常単位、返ってくるのは最小単位。
金額はコインの通常単位で、文字列として送ってください — "1.5" は一・五です。JSONの数値でもなく、最小単位でもありません。
| アセット | 小数桁数 | 送る値 | レスポンスの amount_minor |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
数値ではなく文字列なのは、JSONの数値がIEEE-754のdoubleであり、ナノトン単位の大きな金額はそこに正確には収まらなくなるからです。コインの持つ桁数を超える小数は 422 であって、あなたのお金を黙って丸めることはありません。Webhookは逆です: そちらの amount、fee、credited は最小単位の整数です。読むのが人ではなくコードだからです。
// Send amounts in the coin's normal units, as a string:
const amount = "1.5" // one and a half TON or USDT
// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor) // e.g. 1500000n制限#
最小額、最大額、レート制限。
| 制限 | 値 | 違反時 |
|---|---|---|
| インボイスの最小額 | 0.1 TON · 3 USDT | 422 |
| インボイスの最大額 | 7000 TON · 10000 USDT | 422 |
| ショップごと、1時間あたりのインボイス数 | 60 | 429 |
| 同時に開いているインボイス数 | 20(支払い済みインボイスごとに増加、最大200) | 429 |
| インボイスの有効期間 | 1分〜24時間(デフォルト2時間) | 422 |
| キーごとのAPIリクエスト数 | 1分あたり120 | 429 + Retry-After |
最小額は官僚的な制限ではありません。当社の手数料は定率ですが、決済を受け取ること自体には固定のコストがかかります: 受取アドレスからUSDTを動かすには、まず当社の負担でガス代を入金しなければなりません。数ドルを下回ると手数料が処理コストを賄えず、そのような決済を受け付けることは、動かすのに割に合わないお金をあなたに反映することを意味します。
上限は大口の加盟店に向けたものではなく、単位の取り違えに対する罠です。"5" のつもりで "5000000" を送れば、上限がなければ500万ドルのインボイスができてしまいます: 購入者は不合理な金額を見て離れていきます。本物の注文がこの天井に届くことはなく、間違いは必ず届きます。どちらの上限も設定値(invoice_max_ton、invoice_max_usdt)であり、あなたのショップ向けに引き上げられます — ご相談ください。
1時間あたりの上限と同時オープン数の上限は、どちらもアドレスプールを守るためのものです。開いているインボイスはそれぞれ受取アドレスを1つ占有するため、あるサイトの暴走ループが、そうでなければ全員分のプールを枯渇させてしまいます。新しいショップは同時に20件のインボイスを開けます。実際に回収できたインボイス1件につき枠が1つ増え、上限は200件です。underpaid は開いているものとして数えます — 残りを待ちながら、まだアドレスを保持しているからです。放置されたインボイスをキャンセルすれば、そのアドレスはすぐに返却されます。同じ idempotency_key によるリトライは1時間あたりの上限にカウントされません。
リクエストの上限はAPIキーごとに1分あたり120回です — 1秒に2回で、現実の注文の流量をはるかに上回ります。429 には秒数を示す Retry-After ヘッダーが付きます: きついループで再試行するのではなく、その時間だけ待ってください。再試行を繰り返しても、制限が解ける時刻が先に延びるだけです。
エラー#
実際に遭遇するステータスコード。
エラーはJSONで返り、形は2種類あります。当社または処理コアが判断したものは、detail の下に {code, message} の組を入れます。検証を通らなかったリクエストボディは、代わりにそこへフィールドエラーのリストを入れます。detail.code を読む前に、どちらが届いたのかを確認してください。そして分岐は必ず `code` で行い、`message` では行わないでください — 文言はいつでも変わり得ますが、コードは変わりません。
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| ステータス | 発生条件 | 対応 |
|---|---|---|
| 401 | キーが欠落、誤り、または失効している。 | ヘッダーを確認してください。失効している場合はキーを再発行してください。 |
| 404 | そのようなインボイスが存在しない、または他のショップのものである。 | IDを確認してください。この2つが同じ応答を返すのは意図的で、IDを推測して探れないようにするためです。 |
| 409 | インボイスが、この操作を許さない状態にある。 | まず現在のステータスを確認してください。 |
| 422 | リクエストの形式が不正、または金額がインボイスの制限の範囲外である。 | メッセージには送信された値と制限値の両方が記載されます。 |
| 429 | この1時間のインボイスが多すぎる、同時に開いている数が多すぎる、またはリクエストが多すぎる。 | Retry-After の時間だけ待ってから再試行してください。 |
| 502 | 処理コアに到達できなかった。 | 同じidempotencyキーで再試行してください。 |
コード
当社が判断する側の形は {"detail": {"code": …, "message": …}} です。マーチャントAPIが返すのは次のコードです。
| コード | ステータス | 意味 |
|---|---|---|
| invalid_api_key | 401 | キーが欠落している、形式が不正、未知、または失効している。4つとも同じ応答を返すため、キーを探り当てることはできません。 |
| not_found | 404 | そのようなオブジェクトが存在しない、または他のショップのものである。 |
| invalid_input | 422 | リクエストがコアの検証を通らなかった — 不正な金額、小数点以下の桁数が多すぎる、金額がインボイスの制限の範囲外、など。 |
| conflict | 409 | 操作が現在の状態と矛盾している。たとえば、すでに開いていないインボイスをキャンセルしようとした場合です。 |
| too_many_requests | 429 | レート制限です: 1時間あたりのインボイス数、開いているインボイス数、または1分あたりのリクエスト数。どれだけ待つべきかは Retry-After が示します。 |
| cbc_unreachable | 502 | 処理コアに到達できませんでした。同じ idempotency_key で再試行してください。 |
| webhook_url_rejected | 422 | キーの保存時のみ: WebhookのURLが上記の検査を通りませんでした。detail.reason がどのルールかを示します — scheme、port、ip_literal、local_hostname、private_address、dns_error、credentials など。 |
502はインボイスが作成されなかったことを意味しません — リクエスト自体は通っていて、応答が戻る途中で失われた可能性があります。同じidempotency_keyで再試行すれば、既存のインボイスか新しいインボイスのいずれかを取得でき、決して2つになることはありません。
Checkout: what the buyer sees#
The hosted payment page, and when to build your own.
payment_url points at https://paysell.me/pay/{invoice_id}. One page, no account, no login, mobile first, and nothing for you to build.
On the page
- Your shop's name, the amount and the coin, large, with the invoice's description underneath.
- A countdown to
expires_at— plus the 24-hour grace period when the invoice isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
返金#
顧客に返金する方法。
返金はAPIの呼び出しではなく、サポート経由で行います。 返金とは、人が指定したアドレスへの新しい送金です。そしてAPIの呼び出しで自動的にお金を送り返す決済処理業者とは、攻撃者のアドレスへお金を送らせられる決済処理業者でもあります。ですから、これは意図的に手動になっています。
購入者に返金するには、invoice_id または tx_hash、金額、送金先のアドレスを添えて、アカウント画面からサポートチケットを作成してください。オペレーターが決済を確認し、あなたの残高からお金を動かし、同じチケットで回答します。所要時間は1分ではなく、営業日単位とお考えください。
設計時に織り込んでおきたい影響が2つあります。超過分は全額あなたに反映されます — 当社は一切受け取りません — ので、払いすぎた購入者に差額を返すかどうかはあなたの判断であり、手順は同じです。そして支払い不足のインボイスは、まだ開いているあいだは返金の話ではありません: お金はあなたの残高にあり、アドレスはまだ監視されており、購入者は単に追加入金できます。猶予期間が過ぎ、お金が入ったまま expired になって初めて、判断すべきことが生じます。
テスト#
公開前に連携をテストする方法。
ここで発行されるキーは本番用です: 発行されるキーはすべて、本番のコアとTONメインネットに対する sk_live_ のキーです。テスト環境は別途用意していませんが、そこには利点もあります:実際の注文がたどるのとまったく同じ経路を確認できるのです。
ですから、実際のお金に触れる他のあらゆるものと同じやり方でテストしてください: 少額で行うのです。最小額(0.1 TON または 3 USDT)でインボイスを作り、自分のウォレットから支払い、経路の全体を見届けてください — 決済ページ、Webhook、署名の検証、そして自分の注文が支払い済みに切り替わるところまで。手数料はかかりますし、コインは本当に動きます。
お金を使わずに試せる部分は次のとおりです: インボイスの作成と取得、キャンセル、不正な金額に対する 422、誤ったキーに対する 401、そしてあなた自身の署名検証 — サンプルのボディを自分のシークレットで署名し、自分のハンドラーに渡してみてください。本当に実際の支払いが必要なのは最後の一歩だけです: 本物の payment.credited Webhookです。
サンドボックスや支払いの模擬に依存しないように連携を設計してください:本番の経路のほうが、速く——そして正確に——確認できます。
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
本番公開前チェックリスト#
公開前に確認する十項目。
- キーはサーバー側だけにあり、ブラウザのJavaScriptには決して含まれない。
- Webhookの署名は
"{timestamp}.{raw_body}"に対して、定数時間で検証されている。 - 5分より古い配信は拒否され、サーバーの時計はNTPで合っている。
X-Paysell-Event-Idが重複した場合、2回目は何も行わない。- Webhookは10秒以内に2xxを返し、時間のかかる処理はその後に行われる。
- WebhookのURLはポート443のhttps://のドメインで、手前にリダイレクトがない。
- Webhookを取りこぼしても支障がない: サンキューページか照合の一括処理でインボイスのエンドポイントを読んでいる。
idempotency_keyは注文ごとに一度生成され、リトライ時に再利用される。- 金額は通常単位の文字列で送られ、Webhookの数値は最小単位として読まれる。
- アドレスは返された通りに、変更せずに表示される。
paidだけでなくoverpaidとunderpaidも処理されている。expiredでもpaid_minorにお金が入っていることがある。- 商品の引き渡しは
status: paidまたはoverpaidで行い、コールバックが届いただけでは行わない。 429はRetry-Afterを待ち切ることで処理し、すぐに再試行はしない。- 残高は当社から取得され、別に管理したものを真実として扱っていない。
不明な点がありますか?
このページで疑問が解消しなかった場合、それはドキュメントの不足であり、お知らせいただく価値があります。アカウント画面からご連絡ください。回答だけでなく、ページ自体を修正します。