接受加密货币付款
Paysell 在 TON 网络上结算 TON 和 USDT。您创建一个账单,我们提供一个链接,当资金在链上确认并计入您的余额后,您会收到一个已签名的回调。
概览#
Paysell 做什么,不做什么。
Paysell 是支付处理商,不是钱包。您永远不需要处理私钥、监控区块链或决定交易何时最终确认——这些都由我们负责。
每个账单都有自己的收款地址。买家付款后,我们会等待网络确认转账,扣除手续费,并将余款计入您的余额。您可以提现到任意地址。
支付流程#
六个步骤,大部分由我们完成。
六个步骤,大部分由我们完成:
- 1
客户点击支付
您的服务器调用我们的 API,传入金额和您自己的订单号。
- 2
我们分配一个地址
从预先生成的地址池中取出一个新地址,并与此账单绑定。一个地址只属于一个未结账单,这就是付款与账单匹配的方式。
- 3
客户发送币
客户扫描二维码或复制地址。将其引导至我们返回的
payment_url,页面会为您处理一切——金额、地址、二维码、倒计时、实时状态。 - 4
我们检测到转账
系统查询两个独立的区块链数据源,并比对结果。如果结果不一致,我们会停下来,而不是选择更方便的答案。
- 5
我们等待最终确认
被打包进主链,再加三个区块的确认,大约十五秒。一笔看起来已完成、之后又消失的付款将是您的损失,因此我们不冒这个险。
- 6
计入余额,并通知您
扣除手续费后,余款进入您的余额,同时一个带签名的 Webhook 会携带您的
order_id发送到您的服务器。
从付款到回调大约一分钟:大约十五秒用于网络确认,其余时间是我们对被监控地址的扫描。
钱去了哪里#
手续费,以及它是按什么计算的。
手续费为 0.2%,在您的店铺注册时就已固定。之后即使标准费率发生变化,您的费率也不会变——它作为一个数字写入每一张账单,而不是引用某个设置项。
手续费是按实际到账金额计算的,而不是按账单要求的金额。账单开具 5 USDT,实际收到 20,手续费按 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多付的部分会全额入账——我们不会留下差额。少付会让账单保持开放,买家可以向同一地址补足差额。
快速开始#
五分钟开出第一张账单。
五个步骤。两个是在账户中心点几下,一个是从您的服务器发出一次请求,最后两个会自动完成。
- 1
创建店铺
在您的账户中心完成。它会立即开始接受付款——无需等待审核。验证会在后台静默进行,只限制提现,不影响收款。
- 2
签发 API 密钥
您的店铺 → API 密钥 → 新建密钥。密钥和 Webhook 密钥只会显示一次,之后再也不会显示。请像保管数据库密码一样保管它们,切勿发送到浏览器中。
- 3
创建账单
从您的服务器发出一次请求,返回一个链接。下面四段代码发送的内容完全相同。
- 4
把买家引导到
payment_url这就是完整的收银流程——金额、地址、二维码、倒计时、实时状态——您什么都不用搭建。买家实际看到的内容见支付页面。
- 5
等待 Webhook
款项在链上确认并入账后,我们会向您的服务器 POST 一个带签名的
payment.credited事件。请先验证签名,再把订单标记为已支付——但只有当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。一个密钥只能作用于它自己的店铺。
地址中带有版本:/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 个字符。重试时发送相同的值,您会得到同一张账单,而不是第二张。它是一个请求体字段,而不是 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 未送达时作为回退方案,或用于感谢页面。
查询频率最多每隔几秒一次,并将 Webhook 视为主要渠道。属于其他店铺的账单会返回 404,而不是 403,这样就无法通过探测 ID 来判断其是否存在。
取消账单#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancel关闭一张仍然开放的账单——pending 或 underpaid——并释放其地址。当客户放弃结账时使用——地址是有限资源,归还它们有助于保持地址池健康。
已经不再开放的账单会返回 409。取消一张 underpaid 的账单不会把币退还给任何人:已经入账的钱仍留在您的余额上,被关闭的只是继续接受补款这件事。
Webhook#
会收到什么,以及如何验证它。
创建密钥时设置 Webhook 地址。款项入账时,以及转入额外核查的入金被驳回时,我们都会向该地址发起 POST。每次投递都带签名,并持续重试约一天半,直到您返回 2xx。请凭 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= 后跟十六进制 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):普通的 == 在首字节就不对时会更快返回,而这点时间差足以让人逐字节猜出签名。
时间戳窗口
凡是时间戳与您自己的时钟相差超过五分钟的请求,无论快慢都要拒绝。时间戳被放进被签名的字符串里,正是为了让它无法在不破坏签名的前提下被改动;而窗口才是把这一点变成实际保护的东西。没有窗口,一个被截获过的请求就永远有效,随时可以被重放——单凭签名本身是不会过期的。请让服务器时钟保持 NTP 同步,否则这项检查会开始拒绝正常的投递。
重复
同一事件可能会到达不止一次。这不是 bug:我们会一直重试,直到您返回 2xx;一次已经成功但其响应从未送达我们的投递会被重新发送。请记录 X-Paysell-Event-Id(它也会以 event_id 出现在请求体中),并确保第二次到达不产生任何效果。
重试
款项一经入账,第一次投递就会发出。如果失败——超时、连接被拒、TLS 错误、重定向,或任何非 2xx 状态码——我们会按固定的时间表重试:
1 分钟 → 5 分钟 → 15 分钟 → 1 小时 → 6 小时 → 24 小时总共七次投递,分布在大约 31 小时里。前几次挨得很近,因为常见的原因不过是接收方正在重启、此刻已经恢复;后几次拉得很开,因为对一台已经宕了一天的服务器猛敲,对谁都没有好处。
最后一次尝试之后,该投递会被标记为 dropped,我们自行停止。但它并没有丢失:账户中心的付款记录会显示状态、投递次数和错误类型,还带一个重新发送按钮,可以重新跑完整的七次投递。另一条退路是 GET /api/merchant/v1/invoices/{invoice_id}——账单永远知道自己的状态。
Webhook 地址必须满足什么
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头里的那个没有。
快速响应
任意 2xx 状态码即可,但必须在十秒内——这是我们全部的超时时间,含建立连接。请先响应,慢的处理放在之后;一个非要等自己数据库返回才回应的接收端,迟早会被记为超时并被重试,于是同一个事件您会处理两次。其他任何情况——4xx、5xx、重定向、卡住不回——都算一次失败的投递,重新回到上面的时间表。
关于投递,说实话
有保证的是投递机制本身:大约 31 小时内的七次投递,可在账户中心手动重发,以及一个永远知道真实状态的账单接口。请把流程设计成即使 Webhook 永远不到也不会让您有任何损失——在支付成功页读取账单,或者每小时对一遍未结账单。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` 时
有些入款不会立即计入,而是转入额外核查:金额异常大、币到达了一个没有未结账单的地址,或者我们轮询的两个链上数据源对发生了什么说法不一致。不会有任何损失——钱在那里等待裁定,一旦有结果 Webhook 立即触发,这可能是几分钟后,也可能是几小时后;所以对于显示为 review 的付款,收不到回调是正常的,不是故障。如果它关系到某笔订单,请联系支持并附上 tx_hash。
金额#
发出用常规单位,回传用最小单位。
金额请以币种的常规单位、字符串形式发送——"1.5" 就是一点五。既不是 JSON 数字,也不是最小单位。
| 资产 | 小数位数 | 你发送 | 响应中的 amount_minor |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
用字符串而非数字,是因为 JSON 数字是 IEEE-754 双精度浮点数,以纳吨计的大额数值在其中已无法精确表示。小数位多于币种本身的位数会返回 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 |
| 每个店铺每小时账单数 | 60 | 429 |
| 同时未结的账单数 | 20,每收到一笔付款就增加,最多 200 | 429 |
| 账单有效期 | 1 分钟至 24 小时(默认 2 小时) | 422 |
| 每个密钥的 API 请求数 | 每分钟 120 次 | 429 + Retry-After |
最低限额并非官僚规定。我们的手续费是按比例收取的,但收取一笔付款本身需要一笔固定成本:从收款地址转出 USDT,需要先自掏腰包为其充值 Gas。低于几美元时,手续费无法覆盖处理成本,接受这样的付款就意味着把一笔不划算转移的钱计入您的账户。
上限不是针对大商户,而是为单位错误设下的陷阱。发出 "5000000"、而您本意是 "5",如果没有这道上限,您会得到一张五百万美元的账单:买家看到荒唐的数字直接离开。真实订单永远碰不到这个天花板,写错的订单则一定会碰到。这两个上限都是设置项(invoice_max_ton、invoice_max_usdt),可以为您的店铺调高——找我们说一声即可。
每小时上限和未结账单上限都是为了保护地址池。每张未结账单都占用一个收款地址,否则某个站点上一次失控的循环调用就会把所有人的地址池抽干。新店铺可以同时持有 20 张未结账单;每实际收到一笔付款,额度就增加一张,上限为 200。underpaid 也算未结——它仍然占着自己的地址,等着剩下的钱。取消一张废弃的账单会立即归还它的地址。使用相同 idempotency_key 的重试不计入每小时上限。
请求限制是每个 API 密钥每分钟 120 次——相当于每秒两次,远高于任何真实的订单流量。429 会带一个以秒为单位的 Retry-After 响应头:请按它等待,而不是在紧凑的循环里重试,那只会把窗口推得更远。
错误#
您实际会遇到的状态码。
错误以 JSON 形式返回,共有两种结构。凡是由我们或处理核心判定的错误,都会在 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。两种情况刻意返回相同结果,这样就无法靠试探来枚举 ID。 |
| 409 | 账单当前所处状态不允许此操作。 | 先读取其当前状态。 |
| 422 | 请求格式有误,或金额超出了账单的上下限。 | 错误信息中会同时给出发送的数值和限制值。 |
| 429 | 本小时内账单数量过多、同时未结的账单过多,或请求过多。 | 等满 Retry-After 再重试。 |
| 502 | 未能连接到处理核心。 | 使用相同的幂等密钥重试。 |
错误代码
由我们判定的那种结构形如 {"detail": {"code": …, "message": …}}。以下是商户 API 会返回的代码。
| 代码 | 状态 | 含义 |
|---|---|---|
| invalid_api_key | 401 | 密钥缺失、格式错误、未知或已被吊销。这四种情况的响应完全相同,因此无法靠试探摸出一个有效密钥。 |
| not_found | 404 | 不存在该对象,或它属于其他店铺。 |
| invalid_input | 422 | 请求未通过核心的校验——金额有误、小数位过多,或金额超出账单的上下限。 |
| conflict | 409 | 该操作与当前状态相矛盾,例如取消一张已经不再开放的账单。 |
| too_many_requests | 429 | 触发了频率限制:每小时账单数、未结账单数,或每分钟请求数。Retry-After 会说明需要等待多久。 |
| cbc_unreachable | 502 | 我们无法连接到处理核心。请使用相同的 idempotency_key 重试。 |
| webhook_url_rejected | 422 | 只在保存密钥时出现:Webhook 地址未通过上面那些检查。detail.reason 会指出是哪条规则——scheme、port、ip_literal、local_hostname、private_address、dns_error、credentials 等等。 |
502 并不代表账单未被创建——请求可能已经成功处理,只是响应在返回途中丢失了。使用相同的 idempotency_key 重试,您要么会得到已存在的账单,要么会得到一张新的,绝不会得到两张。
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、金额,以及收款地址。操作员会核对这笔付款,从您的余额中把钱转出,并在同一工单里回复。请按一个工作日来预期,而不是一分钟。
有两个后果值得在设计时考虑进去。多付的部分会全额计入您的账户——我们一分不留——所以要不要把差额退给多付的买家由您决定,走的也是同一条路径。另外,未付足的账单只要还开着就不算退款问题:钱在您的余额上,地址仍在监听,买家直接补足即可。只有在宽限期过后、账单带着钱变成 expired 时,才真的需要做决定。
测试#
上线前如何测试您的接入。
这里的密钥都是正式密钥:签发的每一个密钥都是 sk_live_ 密钥,对应生产核心和 TON 主网。没有单独的测试环境,而这也有好处:您走的正是真实订单将要走的那条路径。
所以请像测试任何涉及真钱的东西那样测试:用小额。开一张最低金额的账单(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}"校验,且使用恒定时间比较。 - 超过五分钟的投递会被拒绝,服务器时钟保持 NTP 同步。
- 重复的
X-Paysell-Event-Id在第二次不会产生任何效果。 - Webhook 在十秒内返回 2xx;较慢的处理放在响应之后。
- Webhook 地址是 443 端口上的 https:// 域名,前面没有任何重定向。
- 漏掉一次 Webhook 也扛得住:在支付成功页或对账扫描时读取账单接口。
idempotency_key每个订单只生成一次,重试时复用。- 金额以常规单位的字符串发出;Webhook 中的数字按最小单位读取。
- 地址按返回时的原样展示,不做任何修改。
overpaid和underpaid都有处理,而不只是paid;expired也可能仍带着paid_minor。- 凭
status: paid或overpaid发货,绝不凭回调本身送达。 - 遇到
429时等满Retry-After再重试,而不是立刻重试。 - 余额从我们这里读取,而不是单独维护一份作为真实来源。
有不清楚的地方?
如果这个页面没有回答您的问题,说明文档存在缺口,值得告诉我们。请在您的账户中心给我们留言,我们会修正页面,而不仅仅是回答问题。