开始
5 节接入非托管加密收款
Finality 让你的网站接受加密货币付款,资金直达你自己的钱包,全程不经过我们。集成方式:后端调一个 API 创建收款、把地址展示给客户,客户付款后我们通过 Webhook 秒级通知你——和你熟悉的传统支付网关体验一致,但没有托管、没有冻结、没有结算周期。
选择你的接入方式
三种方式收加密货币,结算方式完全一样——钱直接进你自己的钱包。挑一个最贴合你现在卖东西的方式就行。
不写代码也能收款
这一节全部靠点击、复制、粘贴完成。只要你会装 WordPress 插件,就能搞定。
第一步:把账户配好(只需一次)
- 1登录后打开「设置」,填上你要收款的钱包地址——你想接受哪条链就填哪条。钱会直接打到这个地址,务必核对清楚。
- 2打开「API 密钥」页面创建一个密钥,复制并妥善保存(只显示一次)。想先不动真钱演练一遍的话,就用 sk_test_ 开头的密钥。
- 3在同一页复制「webhook 密钥」。插件需要用它来确认收到的通知确实是我们发的。
如果你有网店
装上对应平台的插件,把刚才复制的 API 密钥和 webhook 密钥填进去。结账页就会多出加密货币这个付款方式,付了款的订单会自己更新状态。
| WooCommerce | 上传插件 → 设置 → 支付 | 完整结账集成。链上结算完成后订单自动转为「处理中」。 |
| WordPress(没有商店) | 上传插件 → 粘贴一段短代码 | 适合打赏、捐赠、会员费或单件商品。在任意页面放上 [finality_pay amount="49"] 即可。 |
| Shopify | 部署应用 → 安装到店铺 | Shopify 的结账页只允许认证合作伙伴接入,所以这个应用会在顾客下单后发给他一条付款链接,结算完成后给订单打上标签。 |
如果你没有网店
其实连网站都不需要。一条收款链接可以通过邮件、微信、Telegram 发出去,也可以印成二维码。
- 1在后台创建一笔收款:填金额,写清楚是什么费用。
- 2复制链接,或者下载二维码。
- 3发给你的客户。他打开后自己选用哪种币付款,可以从任意钱包转账——直接从交易所提现过来也行。
- 4钱一到账你就会收到邮件通知,后台也能看到这笔收款。
你怎么知道收到钱了
钱直接进你的钱包,中间没有任何「finality 余额」。几秒内你就能在后台看到这笔收款,并附带一条区块链记录的链接,你可以自己去验证。
它是怎么运转的
四步,全自动。你只需实现第 1 步和第 4 步:
客户下单时,你的服务器调用 POST /v1/intents,传金额、链、币种、你的订单号。返回一个专属收款地址和收款页链接。
把收款地址(或二维码 / 我们的托管收款页)展示给客户。客户用任意钱包普通转账即可,无需连接钱包或调合约。
我们的自建全链节点出块即检测到付款,无需等待多个确认。
付款确认后我们 POST 一个签名事件到你的 Webhook。你校验签名、发货 / 跳转成功页。资金已按规则原子到账你的钱包。
5 分钟接入
- 1在控制台创建 API 密钥,配置收款钱包地址和 Webhook 回调 URL。
- 2客户下单时,后端调用创建收款接口,拿到收款地址。
- 3把地址 / 二维码展示给客户,或直接跳转到我们的收款页。
- 4实现 Webhook 处理器:校验签名,收到 payment.settled 即视为付款成功。
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"merchant_order_id": "your-order-9527",
"webhook_url": "https://your-site.com/webhooks/finality"
}'{
"id": "9aRtUe2c-4f1b-4c3a-9e77-2b8d5f0a1c33",
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"pay_address": "7xKq...专属收款地址...3fBw",
"pay_url": "https://finality.money/pay/9aRtUe2c-...",
"status": "pending",
"expires_at": "2026-07-21T12:40:00Z"
}接订阅?同样快
想按周期自动收款(会员 / SaaS)而非一次性收款,只需三步:
- 1在控制台「订阅」页建一个计划(名称 / 金额 / 周期 / 币种),拿到 checkout_url。想零成本跑通,先建一个沙盒测试计划。
- 2把 checkout_url 贴到你的「订阅」按钮上,链接后拼 ?ref=你系统的用户ID 用于对账。
- 3实现同一个 Webhook 处理器:收到 subscription.active 即开通服务,subscription.charged 为续费成功。
沙盒测试计划零成本跑通全链路:在托管收银台点「模拟支付成功」即可立刻收到 subscription.active Webhook,无需真实转账。跑通后换成正式计划即可上线。完整细节见下方「订阅」章节。API 密钥
所有服务端请求用 Bearer Token 认证。公钥(pk_live_)用于前端结账组件,私钥(sk_live_)仅用于服务端,切勿泄露到前端或代码仓库。
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx私钥用于验证的是哈希比对,同时明文加密存储,所以你随时可以在后台或 GET /v1/merchant/keys 的 key_secret 字段里取回它——不必担心创建时没抄下来。想不动真钱把整个流程走一遍,就用 sk_test_ 密钥:行为完全一致,托管收银台会多出一个「模拟支付」按钮。★沙盒与生产**只由密钥前缀决定**,不能通过请求字段声明,所以拿着生产密钥的人无法伪造已支付订单。
创建收款
创建一笔收款请求。返回专属收款地址,客户付款到该地址即完成。
| chain | string | 收款链:solana / base / bsc / robin(eth 即将支持) |
| token | string | 收款币种:USDC / USDT |
| amount | string | 金额(USD,稳定币 1:1)。用字符串避免精度丢失 |
| merchant_order_id | string | 你系统的订单号,唯一。用于对账 + 幂等 |
| webhook_url | string? | 本单回调地址,覆盖账户默认 Webhook |
| success_url | string? | 支付成功回跳地址:顾客付款成功后自动跳回(带 intent_id + merchant_order_id)。由你的服务端传,安全可靠 |
| splits | array? | 多受益方分账(见分账章节) |
| expiry_secs | number? | 有效期秒数,默认 1800(30 分钟)。对波动币来说这同时也是**锁价窗口**。 |
| metadata | object? | 自定义键值,原样在 Webhook 回传 |
merchant_order_id 重复创建返回同一个收款 Intent,不会重复收款。网络抖动重试安全。查询收款
随时查询一笔收款的最新状态。推荐用 Webhook(实时推送),此接口用于对账或 Webhook 失败时的轮询兜底。
| pending | 等待客户付款 |
| detected | 出块检测到(0-conf) |
| confirmed | 达到确认阈值 |
| settled | 已原子分账,资金到账 —— 可安全发货 |
| expired | 超时未付 |
顾客付了但没付够
精确金额模型下,「少付」是真实且高频的场景。收银台接口会额外返回三个字段,让你能显示「还差多少」而不是干等:
| received_atomic | string | 已收到的原子量(字符串)。 |
| remaining_atomic | string | 还差多少原子量。已付够时为 "0"。 |
| is_partial | boolean | 付了一部分但没付够时为 true。一分未付或已付够都是 false。 |
/v1/checkout/:id 上返回,/v1/intents/:id 上没有。在后者找不到它们不是 bug。另外查询失败时会静默返回 received_atomic: "0",与「真的一分没付」无法区分。收银台端点
如果你用我们的托管收银台(建单返回的 checkout_url),这一节可以跳过——顾客点开就能付,什么都不用接。下面这些是给「要自己做收银台界面」的商户用的。
checkout_url 给顾客,托管页面已经处理好二维码、多币种选择、少付提示、实时到账、成功回跳。自建的唯一理由是你要把付款流程完全嵌进自己的界面。查收银台状态(含少付信息)
无需鉴权,顾客侧可直接调。这是唯一返回少付状态的端点——received_atomic(已收到)、remaining_atomic(还差多少)、is_partial(是否付了但没付够)。/v1/intents/:id 上没有这三个字段。
构造付款交易(连钱包付)
返回一笔未签名交易,供顾客的钱包直接签名发送。注意这不是必须的——顾客完全可以用任何钱包普通转账到 pay_address,效果一样。这个端点只是给「想做连钱包一键付」的界面用。
| owner | string | 顾客钱包地址。必填。但只有 Solana 分支真正使用它,EVM 分支不读——仍需传,否则请求解析失败。 |
// EVM (ERC20)
{ "chain": "base", "token": "USDC", "is_native": false,
"to": "0x8335...", // ERC20 时 to = 代币合约
"data": "0xa9059cbb...",
"value_wei": null,
"pay_address": "0xPay...", "amount_atomic": "49000000" }
// Solana
{ "chain": "solana", "token": "USDC", "is_native": false,
"solana_unsigned_tx": "<base64>",
"pay_address": "7xKq...", "amount_atomic": "49000000" }data 构造失败时会是 null 而不是报错,要判空;②本端点不校验订单状态,已结算或已过期的单照样返回交易参数,你得自己先查状态;③多币种订单未选币时会返回 400,必须先调 select。上报收银台曝光
自建收银台时调一下,否则转化漏斗的分母永远是 0,收款分析里看不到浏览量。恒返回 204,即使订单号不存在也不报错,可以放心 fire-and-forget。服务端不去重,去重靠你自己(比如 sessionStorage)。
沙盒测试(零成本跑通全流程)
用 sk_test_ 密钥建的单是沙盒单,不涉及真实资金。这个端点把沙盒单直接推到已结算,并发出与生产完全同格式的真 webhook——你的处理逻辑能在上线前完整验证一遍。
pending 状态,而 pending → settled 是非法状态转移,此时调用会返回 500。必须等订单进入 detected 之后再调。托管收银台上的「模拟支付」按钮已经处理好这个时序,自建时请注意。# ① 用沙盒密钥建单
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "chain":"base", "token":"USDC", "amount":"49.00",
"merchant_order_id":"test-1",
"webhook_url":"https://your-site.com/webhooks/finality" }'
# ② 等订单进入 detected 后, 模拟支付 (刚建好的 pending 单会 500)
curl -X POST https://finality.money/v1/checkout/<intent_id>/simulate-pay
# ③ 你的 webhook 端点会收到与生产同格式的事件:
# { "event":"payment.settled", "settle_tx":"SANDBOX",
# "livemode":false, ... }| settle_tx | "SANDBOX" | 恒为字符串 "SANDBOX",不是真实链上哈希。你的对账逻辑要能容忍。 |
| livemode | false | 恒为 false。生产 webhook 里是 true——用它区分测试与真实收款。 |
| WebSocket | — | 模拟支付**不推 WebSocket**,只发 webhook。前端要看到状态变化请轮询收银台端点。 |
| 重复调用 | 200 | 订单已是已结算时幂等返回 200,不会重复发 webhook。 |
is_test 字段虽然存在但被后端完全忽略,这是刻意的安全设计——否则拿着生产密钥的人可以伪造已付款订单)。切换上线只需把密钥换成 sk_live_,其他什么都不用改。多币种收款(让顾客自己选)
建单时不传 token 就进入多币种模式:顾客在收银台自己挑用哪条链、哪个币付。适合面向全球顾客、不想替他们决定用什么币的场景。
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "amount": "25.00",
"merchant_order_id": "order-8802",
"currencies": ["USDC","SOL","ETH"] }'
# 响应: 此时还没有 pay_address, 等顾客选币
# { "id":"...", "mode":"multi_currency", "status":"awaiting_currency",
# "options":["solana:USDC","solana:SOL","base:USDC","base:ETH"],
# "pay_url":"https://finality.money/pay/..." }pay_address 是 null,状态是 awaiting_currency,因为还不知道顾客要用哪个币,地址无法派生。options 里是最终可选项——已经自动剔除了你没配收款地址的链。顾客选币
顾客选定后调这个端点。无需鉴权(顾客侧直接调)。它会一次做完四件事:锁定汇率、派生该币的收款地址、把状态推进到 pending、并把地址登记进链上检测索引。
| currency | string | 格式 "chain:symbol"(推荐,如 "solana:USDC")或裸 symbol(会匹配候选池里第一个同名币)。 |
curl -X POST https://finality.money/v1/checkout/<intent_id>/select \
-H "Content-Type: application/json" \
-d '{ "currency": "solana:USDC" }'
# 响应: 地址已派生, 汇率已锁定
# { "chain":"solana", "token":"USDC", "status":"pending",
# "pay_address":"7xKq...", "token_amount":"25.00",
# "amount_atomic":"25000000", "expires_at":"..." }409。这是刻意的改址防护(否则已经展示给顾客的地址可能被换掉)。重复提交同一个币种是幂等的,只会刷新过期时间。真要换币,等订单过期后重新下单。| 候选币种校验 | 400 | 带 "chain:" 前缀的写错会整单拒绝;裸 symbol 写错则静默忽略,可能导致可选项变少。 |
| 收款地址过滤 | 400 | 没配收款地址的链会被自动剔除。全被剔完则建单失败,提示先去后台配地址。 |
| 不支持分账 | — | 多币种模式下 splits 会被忽略,全额进商户默认收款地址。需要分账请用固定币种模式。 |
| 过期时间重置 | 1800s | 选币时锁价窗口固定 1800 秒,会覆盖你建单时设的 expiry_secs。 |
订单查询与搜索
批量拉取你的收款单,支持按时间、状态、关键词筛选,游标翻页。这是每日对账最常用的接口——不用一个个 GET 单号。
| period | string? | today / 7d / 30d。传别的值或不传 = 全部时间。注意 today 实为「最近 24 小时」,不是自然日。 |
| status | string? | 逗号分隔多选,如 detected,confirmed。不区分大小写。 |
| search | string? | 模糊搜索,匹配这 4 个字段中任意一个:merchant_order_id、收款地址、检测 tx、结算 tx。 |
| limit | number? | 默认 100,服务端限制在 1–5000 之间。 |
| cursor_created_at | string? | 翻页游标,原样回传上一页 next_cursor 里的同名字段。 |
| cursor_id | string? | 翻页游标,与 cursor_created_at 必须成对传。 |
created、awaiting_currency、pending、detected、confirmed、settled、expired、failed。后端从不产生 detecting 这个值——想查「已到账待确认」请用 detected,confirmed。游标翻页
按 (created_at, id) 倒序的键集翻页,不用 OFFSET,所以翻到第 10 万条也不会变慢,且不会因为期间有新单插入而重复或漏掉记录。
第一页
curl "https://finality.money/v1/merchant/intents?limit=50&status=settled" \
-H "Authorization: Bearer sk_live_..."
# 响应尾部带游标 (has_more=false 时 next_cursor 为 null)
# { "intents": [...], "has_more": true,
# "next_cursor": { "cursor_created_at": "2026-08-07T09:12:33Z",
# "cursor_id": "6f1c..." },
# "stats": { "total": 1284, "settled": 1190, "settled_usd": "48210.50" } }
# 下一页: 把 next_cursor 两个字段原样回传
curl "https://finality.money/v1/merchant/intents?limit=50&status=settled\
&cursor_created_at=2026-08-07T09:12:33Z&cursor_id=6f1c..." \
-H "Authorization: Bearer sk_live_..."cursor_id 不传 cursor_created_at 会被完全忽略(相当于又从第一页开始),这是最容易踩的坑。stats 与列表口径不一致,这是刻意的:stats 只受 period 影响,忽略 status/search/游标,且排除沙盒单;而 intents 列表包含沙盒单。要算「筛选后的合计」请自己累加列表。单笔全链路追溯
一次拿到某笔订单的完整历史:订单本身 + 每一笔链上到账 + 结算记录 + 所有 webhook 投递结果。排查「顾客说付了但我没发货」时用这个。
| intent | object | 订单完整字段。 |
| payments | array | 每一笔链上到账,含付款方地址 sender、tx 哈希、金额、检测时间,按时间正序。 |
| settlement | object|null | 结算记录(分账明细、协议费、发票号)。未结算时为 null。 |
| webhooks | array | webhook 投递记录,含尝试次数、最后错误、是否进死信。最多返回 50 条,倒序。 |
优惠券
给一次性收款打折。创建一个券码(如 SAVE20 = 减 20%),顾客在结账时输入即按折后价支付。你也可以在建单时直接引用券码,让顾客进来就是折后价、无需手动输入。
用 sk_live 建券。券码在你的商户内唯一(大小写不敏感)。
| code | string | 券码(如 SAVE20)。商户内唯一 |
| discount_type | string | "percent"(百分比折扣)或 "fixed"(定额减免) |
| discount_value | string | percent:折扣百分点(20=减 20%);fixed:减去的 USD 金额 |
| max_redemptions | number? | 最大可用次数(0 或缺省=不限) |
| expires_at | string? | 过期时间(ISO 8601;缺省=永久有效) |
curl -X POST https://finality.money/v1/coupons \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "SAVE20", "discount_type": "percent", "discount_value": "20" }'建收款单时带上 coupon_code,后端自动校验并按折后价收款——顾客进收银台看到的就是打折后的价格:
{
"chain": "base",
"token": "USDC",
"amount": "100.00",
"merchant_order_id": "order-8801",
"coupon_code": "SAVE20"
}零合约改动、100% 非托管。Webhook 回调
收款状态变化时,我们向你配置的地址 POST 一个签名事件。这是你确认「付款成功」的主要方式。
唯一代表「可以发货」的事件
资金已在链上完成分账,钱已在你的钱包里。**只有这个事件**才应该触发发货。
| payment.settled | 可发货 ✓ | 合约已完成分账,属于你的那份已进你的钱包,不可逆。这是你的绿灯。 |
两个代表「停下」的事件
这两种情况钱都动了,但你**不能交付**。每一个都需要人工看一眼。
| payment.blocked | 绝不能发 | 付款方地址命中制裁名单,我们在结算发生**之前**就阻断了,钱从未转给你。不要交付——这需要人来做合规判断,不能自动处理。 |
| payment.late | 绝不能发 | 订单过期之后才到的款。钱在那笔订单的收款地址里,你可以在后台取回。发货前先联系顾客——这单已经关闭了。 |
其余事件(仅供参考)
用来展示状态和对账很有用,但它们本身不是发货信号。
| payment.detected | 先别发 | 链上看到了,但还没最终确认(零确认)。适合显示「已收到付款,确认中…」——据此发货有风险。 |
| payment.partial | 先别发 | 顾客付少了。订单会一直开着,他补足后正常结算。记得告诉他还差多少。 |
| payment.overpaid | 可发货 ✓ | 顾客付多了。全款已经到你这里,订单算完成——多付的那部分留在收款地址里,可在后台「未结算资金」中提取。 |
| payment.expired | 先别发 | 窗口关闭前没人付款。 |
| payment.failed | 先别发 | 链上结算失败。系统会自动重试;如果反复出现请查看后台。 |
| payment.refunded | — | 历史事件,已不再产生:平台不再提供退款登记入口。结算完成后资金已在你的收款地址,退款请自行处理。保留在此仅供你的旧 webhook 处理逻辑对照。 |
| payment.confirmed | — | 保留事件。当前「检测到即结算」模型下不会触发。 |
订阅相关事件
| subscription.active | — | 订阅已开通,首期已扣款。 |
| subscription.charged | 可发货 ✓ | 某一期扣款成功——给顾客延长服务期。 |
| subscription.insufficient_balance | — | 顾客的 vault 余额不多了,下一期可能扣不上。提醒他充值。 |
| subscription.expired | — | 过了宽限期仍未扣款成功。订阅**永久结束**,顾客需要重新订阅。 |
| subscription.canceled | — | 顾客在链上取消了订阅。停止提供服务。 |
| subscription.completed | — | 固定期数订阅已把约定的期数全部付完。这是正常结束 —— 既不是取消,也不是欠费。 |
| subscription.migrated | — | 顾客切换了套餐(升级或降级)。这不是取消 —— 请继续提供服务,注意 subscription_id 会变。 |
| subscription.trial_ending | — | 免费试用即将结束——第一次真实扣款要来了。 |
| subscription.renewal_reminder | — | 即将续费。适合提醒顾客的时机。 |
subscription.migrated 不是取消。顾客升级或降级时,旧订阅会在链上关闭、同时新建一条全新订阅 —— 因此 subscription_id 会变。事件体里带有 old_subscription_id、new_subscription_id、old_plan_id、new_plan_id。如果你的记录是按 subscription_id 建索引的,必须把映射改指到 new_subscription_id,否则下一条 subscription.charged 会带着一个你不认识的 id 到达,看起来就像陌生人的付款。切勿把这个事件当成停服信号:顾客刚刚付钱换了套餐,此时断掉他的服务恰恰是最错的做法。client_reference_id 和 metadata 会继承到新订阅上,所以改用 client_reference_id 做索引可以完全避开这个坑。POST https://your-site.com/webhooks/finality
X-Finality-Signature: t=1760000000,v1=3a7bd3e2360a... // HMAC-SHA256(secret, "{t}.{raw body}"), hex
X-Finality-Event-Id: 8f2c1a9e-... // stable id — use it to deduplicate
X-Finality-Event-Type: payment.settled
{
"event": "payment.settled",
"intent_id": "9aRtUe2c-4f1b-4c3a-9e77-2b8d5f0a1c33",
"merchant_order_id": "your-order-9527",
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"livemode": true, // false for sandbox (sk_test_) orders
"settle_tx": "5Kd...9Qm", // on-chain proof
"payer": "3fBw...7xKq", // who actually paid, read from the chain
"metadata": { "...": "whatever you sent at creation" }
}投递失败会怎样
| 判定成功 | 2xx | 只要返回 2xx 就算成功。其他状态码一律当失败重试。 |
| 最多尝试 | 16 | 16 次仍失败则进死信队列,停止自动重试。 |
| 退避间隔 | 1s → 1h | 指数退避加满抖动,最短 1 秒,最长 1 小时,总覆盖约 24–72 小时。 |
| 单次超时 | 10s | 你的接口 10 秒内没响应就算这次失败。请先返回 200 再做耗时处理。 |
| 熔断保护 | 5 / 60s | 连续 5 次失败会暂停投递 60 秒再试探。熔断期间不消耗重试次数。 |
dead=true 的记录,逐条补发。手动重放
接口宕机、部署事故导致漏收事件时,用这个补发。先用 GET /v1/merchant/webhooks 找出失败的事件 ID。
# 先查哪些事件失败了 (dead=true 即已进死信)
curl "https://finality.money/v1/merchant/webhooks?intent_id=6f1c...&limit=100" \
-H "Authorization: Bearer sk_live_..."
# 重新投递某一条
curl -X POST https://finality.money/v1/merchant/webhooks/8f2c1a9e-.../replay \
-H "Authorization: Bearer sk_live_..."
# → { "ok": true, "replayed": "8f2c1a9e-..." }intent_id 筛选和 limit(上限 500),没有翻页游标。要长期留存请自行定期拉取。轮换签名密钥
怀疑密钥泄露时立即轮换。返回新的 whsec_ 开头的密钥。此接口只接受登录态,API key 调不通。
订阅(自动续费)
订阅像 Stripe 一样按周期自动收款——但完全非托管:钱始终在顾客自己的链上金库里,合约每期只能扣走约定的金额,顾客随时可以取回。你不需要写代码:建套餐、生成顾客链接,在后台就能全部完成。
在后台「订阅」页填名称 / 价格 / 周期 / 币种。设置到这里就结束了。
在套餐那一行点「生成链接」,为某位顾客生成专属链接,发给他。全程不用写代码。
他打开链接、连接钱包、付第一期。之后每期都会自动续费。
订阅会立刻出现在后台。如果你有网站需要自动为顾客开通服务,再加上 Webhook(见下文)——否则看到这里就够了。
如果你不写程序,要做的就只有这些。在后台大约五分钟就能完成。
- 1打开「设置」,填上你想收款的钱包地址,以及你愿意接收的区块链。不填这一步顾客没法付款。
- 2进入「订阅」→「新建套餐」。填名称、价格、多久收一次(按月、按年,或你自己定的周期),保存。
- 3新套餐会出现在列表里。在它那一行点「生成链接」,填上这位顾客的名字或你自己的备注,点「创建」。
- 4把弹出来的链接复制下来,用你平时联系顾客的方式发给他——邮件、微信、随便什么都行。
到这里就完成了。顾客付款后,订阅会出现在你的「订阅」页面并自动续费。每位顾客一条专属链接,所以你永远知道是谁付的钱。
用代码来做
建计划可在后台点建,也可用 API(sk_live 鉴权)。返回体含可直接分享的 checkout_url。
| name | string | 计划名(展示给顾客,如 Pro Monthly) |
| amount | string | 每期金额。usd 模式=美元数(稳定币 1:1);native 模式=token 数量(如 1 SOL) |
| pricing_mode | string | "usd"(稳定币计价)或 "native"(币本位),缺省 usd |
| chain | string | 收款链,如 solana / bsc / base |
| token | string | 币种 symbol,如 USDC / SOL |
| cycle | string | 周期:month / year / week / day / 或秒数(自定义任意间隔,最小 3600) |
| grace_seconds | number? | 宽限期秒数:到期后仍未扣成的容忍窗口,超过则订阅作废。缺省平台默认 7 天 |
| trial_seconds | number? | 试用期秒数:首期延后扣款。缺省 0(无试用) |
| max_cycles | number? | 一共扣几期。缺省或 0 = 一直续订直到客户取消(默认值,也是本参数存在之前所有套餐的行为)。N > 0 = 扣满 N 期自动结束;期数焊进 vault 地址并由合约强制,第 N+1 期会直接 revert —— 谁都多收不了,连我们也不行。 |
| public_checkout | boolean? | 传 true 会得到一条人人可订的公开链接(适合公开价目页、打赏捐赠)。不传则该套餐只接受邀请:checkout_url 返回 null,改为给每位顾客单独生成链接。套餐建好后这项不能再改。 |
{
"id": "0f4c8e2a-...",
"name": "Pro Monthly",
"amount": "9.99",
"chain": "solana",
"token": "USDC",
"cycle_seconds": 2592000,
"max_cycles": 0, // 0 = 一直续订到取消为止
"public_checkout": false, // 你没有传 public_checkout,所以这个套餐只接受邀请
"checkout_url": null, // 因为套餐是邀请制,所以是 null —— 这是预期结果
"payout_ready": true // false 表示你还没配收款地址:顾客无法付款
}checkout_url 返回 null 是正常的,不是出错。除非你传 public_checkout: true,否则套餐只接受邀请——因为公开链接无法告诉你是谁付的钱。要拿到邀请制套餐的链接,请为顾客生成专属链接(见下文)。完整后端示例 · Node.js
把建计划、生成带用户身份的订阅链接、接收 Webhook 串成一个真实可跑的 Express 服务。用你的 sk_live 建计划,用 webhook_secret 验签,用 client_reference_id 定位你系统里的用户,按事件类型开通 / 续费 / 停用。
import express from "express";
import crypto from "crypto";
const SK = process.env.FINALITY_SECRET_KEY; // sk_live_... (服务端)
const WEBHOOK_SECRET = process.env.FINALITY_WEBHOOK_SECRET;
const API = "https://finality.money";
// 1. 建订阅计划 (启动时或后台点一次即可, 拿到 plan_id)
async function createPlan() {
const r = await fetch(`${API}/v1/subscriptions/plans`, {
method: "POST",
headers: { Authorization: `Bearer ${SK}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Pro Monthly", amount: "9.99", pricing_mode: "usd",
chain: "solana", token: "USDC", cycle: "month",
}),
});
return await r.json(); // { id, checkout_url, ... }
}
// 2. 给某个登录用户生成订阅链接 (?ref= 你系统的用户 ID)
function subscribeLink(plan, userId) {
return `${plan.checkout_url}?ref=${encodeURIComponent(userId)}`;
}
// 3. 接收 Webhook: 验签 (原始字节!) + 幂等 + 按事件类型开通/续费/停用
const app = express();
// 请求头形如 t=1735689600,v1=abc123… —— 必须先按逗号拆开,不能整串比对。
// 被签名的原文是「时间戳 + 英文句点 + 原始请求体」—— 时间戳也在 HMAC 里面。
function verifySignature(rawBody, header) {
if (typeof header !== "string") return false;
let t = null, v1 = null;
for (const part of header.split(",")) {
const [k, val] = part.trim().split("=");
if (k === "t") t = Number(val);
else if (k === "v1") v1 = val;
}
if (!Number.isFinite(t) || !v1) return false;
// 超出 5 分钟(含来自未来的)一律拒绝。这一步才是防重放的关键。
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]);
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(signed).digest("hex");
// ★ 必须先比长度: timingSafeEqual 遇长度不等会抛 RangeError (不是返回 false),
// 攻击者发个短签名头就能让这个端点 500。
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/finality", express.raw({ type: "*/*" }), (req, res) => {
if (!verifySignature(req.body, req.headers["x-finality-signature"]))
return res.sendStatus(400);
const evt = JSON.parse(req.body); // 验签通过后再解析
const userId = evt.client_reference_id; // 你 ?ref= 传的用户 ID
const eventId = req.headers["x-finality-event-id"];
if (alreadyHandled(eventId)) return res.sendStatus(200); // 幂等, 防重复开通
switch (evt.event) {
case "subscription.active": activateUser(userId, evt.subscription_id); break;
case "subscription.charged": extendUser(userId, evt.next_charge_at); break;
case "subscription.expired": deactivateUser(userId); break;
case "subscription.completed": deactivateUser(userId); break; // 已付清,不是失败
}
markHandled(eventId);
res.sendStatus(200);
});
app.listen(3000);把链接放上你的网站
直接把 checkout_url 用作按钮链接即可。支付成功回跳地址推荐在后台配置——在「设置」里填全局默认回跳,或在建每个计划时单独指定(留空则用全局)。这样存在服务端,顾客无法通过改链接篡改回跳目标,最安全、也最省事。用户身份 ref(每个用户不同)则通过链接 query 动态传:
| ref | string? | 仅供参考,不是可信的 client_reference_id。?ref= 里的内容由顾客在地址栏输入,他自己就能改。它在 Webhook 里以 untrusted_ref 回传,而 client_reference_id 会是 null。绝不能拿它开通服务;要拿到可信标识,请用下方的「专属链接」。 |
| success_url | string? | 支付成功回跳地址。推荐在后台配(plan 级或全局默认);也可用此 query 参数临时覆盖。回跳时自动带上 subscription_id 与 client_reference_id。 |
| metadata | json? | 任意自定义键值(URL 编码的 JSON 对象),原样回传 Webhook。 |
https://finality.money/subscribe/0f4c8e2a-...?ref=user_8837success_url query 覆盖(记得 URL 编码)。顾客支付成功后会看到「返回商家」按钮并在 5 秒后自动跳回,你的落地页收到 subscription_id 即可开通服务。订阅 Webhook 事件
订阅生命周期的每个节点都会 POST 到你配置的 Webhook 地址(与一次性收款共用同一地址和签名密钥,在「设置」页配置)。所有事件都携带 owner_address、client_reference_id、metadata,方便你精准对账。
| subscription.active | event | 首期已到账、订阅生效 —— 此时开通服务 |
| subscription.charged | event | 某一期自动续费成功 |
| subscription.insufficient_balance | event | 金库余额不足下一期,续费将失败(提醒用户充值) |
| subscription.expired | event | 超宽限期仍未扣成,订阅作废(用户需重新订阅) |
| subscription.canceled | event | 顾客在链上取消了订阅。停止提供服务。 |
| subscription.completed | event | 固定期数订阅已把约定的期数全部付完。这是正常结束 —— 既不是取消,也不是欠费。 |
| subscription.migrated | event | 顾客切换了套餐(升级或降级)。这不是取消 —— 请继续提供服务,注意 subscription_id 会变。 |
| subscription.trial_ending | event | 免费试用即将结束——第一次真实扣款要来了。 |
| subscription.renewal_reminder | event | 即将续费。适合提醒顾客的时机。 |
subscription.migrated 不是取消。顾客升级或降级时,旧订阅会在链上关闭、同时新建一条全新订阅 —— 因此 subscription_id 会变。事件体里带有 old_subscription_id、new_subscription_id、old_plan_id、new_plan_id。如果你的记录是按 subscription_id 建索引的,必须把映射改指到 new_subscription_id,否则下一条 subscription.charged 会带着一个你不认识的 id 到达,看起来就像陌生人的付款。切勿把这个事件当成停服信号:顾客刚刚付钱换了套餐,此时断掉他的服务恰恰是最错的做法。client_reference_id 和 metadata 会继承到新订阅上,所以改用 client_reference_id 做索引可以完全避开这个坑。subscription.completed 意味着顾客把钱付清了。它只会在用 max_cycles 建的套餐上触发,表示约定的每一期都扣成功了。你可以像任何付费期结束那样停止提供服务 —— 但千万不要把它当成 subscription.expired(没按时付款)或 subscription.canceled(顾客主动退订)。这位顾客什么错都没犯,对他发催缴邮件、或把他归进流失挽回名单,都是错的。事件体里带有 cycles_charged 和 max_cycles 供你对账。已结束的订阅无法延期 —— 期数焊死在 vault 地址里 —— 想让他继续,请邀请他重新订阅一次。POST https://your-site.com/webhooks/finality
X-Finality-Signature: t=1760000000,v1=3a7bd3e2360a...
X-Finality-Event-Type: subscription.active
X-Finality-Event-Id: evt_5f8c1a2b-...
{
"event": "subscription.active",
"subscription_id": "7b1e9f04-...",
"plan_id": "0f4c8e2a-...",
"livemode": true,
"chain": "solana",
"token": "USDC",
"vault": "9xQ...v1k",
"owner_address": "3fBw...7xKq",
"client_reference_id": "user_8837",
"metadata": { "plan_tier": "pro" },
"amount": "9.99",
"subscribe_tx": "5Kd...9Qm",
"next_charge_at": "2026-08-30T12:00:00Z"
}client_reference_id(你在服务端生成专属链接时写下的 ID)把这条订阅映射回你系统里的用户——它是顾客唯一改不了的身份字段。owner_address 是订阅者的钱包地址,subscription_id 用于后续查询与对账。X-Finality-Event-Id 头。请以它为幂等键——对同一个 Event-Id 只处理一次,防止投递重试导致重复开通 / 重复续费。payload 里的 livemode 字段(true=生产、false=沙盒,与 Stripe 同款)用来区分正式与测试事件,沙盒测试计划产生的事件 livemode 为 false。专属订阅邀请(给某位顾客单独优惠)
想给某位具体顾客单独的优惠(如首月免费、专属折扣)?建一个一次性「订阅邀请」——它是一条限时、用一次即失效的专属链接,可覆盖计划的促销条款,且优惠同样焊进金库(100% 去中心化,顾客改不了)。对标 Stripe 的 Checkout Session(subscription 模式)。返回的 checkout_url 直接发给这位顾客即可。
| plan_id | string | 绑定的订阅计划 ID |
| client_reference_id | string? | 这位客户在你自己系统里的用户 ID / 订单号。它会随订阅一起存下,并在每一条回调里回传(首次付款以及此后每次续费)—— 你就是靠它把一笔链上付款对应回自己系统里的账户。强烈建议填写。 |
| promo_amount | string? | 覆盖促销价(与计划金额同单位;0=促销期免费)。留空则沿用计划促销 |
| promo_cycles | number? | 覆盖促销期数(前 N 期用促销价)。留空=不覆盖,沿用计划 |
| customer_email | string? | 顾客邮箱(可选,便于你识别这条邀请发给了谁) |
| expires_in_secs | number? | 邀请有效期秒数(过期链接自动失效)。缺省平台默认 |
{
"id": "3d9f...",
"checkout_url": "https://finality.money/subscribe/0f4c8e2a-...?session=3d9f...",
"status": "open",
"expires_at": "2026-08-05T00:00:00Z"
}完整接入(你这边需要两个接口)
这就是完整闭环:你创建链接时带上自己的用户 ID,付款到账时我们把同一个 ID 回传给你 —— 你的服务器据此知道该给谁开通。只需替换 API key、plan_id 以及你自己的开通逻辑。
// 下面只有两个地方需要改。
const FINALITY_KEY = "sk_live_..."; // ← 1. 改这里:后台 → 开发者 → API 密钥
const PLAN_ID = "0f4c8e2a-..."; // ← 2. 改这里:后台 → 订阅 → 你的套餐
// 你的网站:用户点「订阅」→ 你为他生成一条专属链接。
app.post("/subscribe", async (req, res) => {
const r = await fetch("https://finality.money/v1/subscriptions/sessions", {
method: "POST",
headers: {
"Authorization": "Bearer " + FINALITY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
plan_id: PLAN_ID,
client_reference_id: req.user.id, // ← 你自己的用户 ID,会原样回传给你。
}),
});
const session = await r.json();
res.redirect(session.checkout_url); // 把顾客送去收银台
});// Your site: we call this when they pay, and on every renewal.
app.post("/webhooks/finality", async (req, res) => {
// Always verify the signature first (see the Webhooks section).
if (!verifyFinalitySignature(req)) return res.sendStatus(400);
const { event, client_reference_id, subscription_id } = req.body;
if (event === "subscription.active" || event === "subscription.charged") {
// client_reference_id is exactly the req.user.id you sent above.
await db.users.update(client_reference_id, {
subscribed: true,
finality_subscription_id: subscription_id,
});
}
// "completed" = fixed-term plan paid in full. Same action (access ends),
// but it is NOT a failure — never dun or win-back these customers.
if (event === "subscription.canceled" || event === "subscription.expired"
|| event === "subscription.completed") {
await db.users.update(client_reference_id, { subscribed: false });
}
res.sendStatus(200); // respond 200 quickly; we retry on failure
});升级降级套餐涉及地址迁移,单独一节讲:订阅升降级。
订阅数据读接口(bot / 自建后台)
| GET /v1/subscriptions | sk | 列出你的全部订阅(每条附实时余额、还够扣几期)——续费健康度看板的数据源。 |
| GET /v1/subscriptions/overview | sk | 按套餐聚合的总览:每个套餐的顾客数、预计到手、已收、待处理。 |
| GET /v1/subscriptions/plans | sk | 列出全部套餐(含归档的不返回)。 |
| GET /v1/subscriptions/plans/:id/insights | sk | 单个套餐的深度洞察:转化漏斗、弃单原因、钱包/链分布。 |
该发哪条链接
订阅链接有两种,每个套餐只能是其中一种,创建时就定死了。发错链接是客户跟你说「你们页面打不开」最常见的原因,所以值得花两分钟弄清哪种是哪种。
/subscribe/{plan_id}一条链接反复用,贴哪儿都行:定价页、推文、二维码。正因为谁都能打开,光凭这条链接你无法知道是谁付的款。适合捐赠、打赏、公开定价页。
/subscribe/s/{token}你为某一位具体客户生成,并带上他在你自己系统里的 ID。只能用一次,用过即失效。他付款后那个 ID 会回传给你,你的服务器据此知道该给哪个账户开通。
在这个格式之前生成的链接照常有效 —— 旧的 /subscribe/{plan_id}?session=… 形态仍然能正常打开,你之前发出去的链接不受任何影响。请始终直接使用 checkout_url 返回的那条链接,不要自己拼。
哪个标识可以信
每条回调里都有两个身份字段,它们的可信度并不相同。client_reference_id 是你在服务端创建专属链接时写下的值,客户既看不到也改不了。untrusted_ref 则来自他打开的那个网址里的 ?ref= 参数,他可以把它改成任何值,包括别人的用户 ID。
{
"event": "subscription.active",
"subscription_id": "9c1e...",
// ✅ TRUSTED — set by you, server-side, when you created the session.
// The customer cannot influence this. Safe to activate an account on.
"client_reference_id": "user_8837",
// ⚠️ NOT TRUSTED — taken from ?ref= in the URL the customer opened.
// They can edit it to anything, including someone else's ID.
// Use it as a hint for reconciliation. NEVER to decide who gets access.
"untrusted_ref": null
}client_reference_id 开通服务。客户真金白银付了链上的钱,证明的是「这笔钱是真的」,不是「他是谁」—— 只有当这个身份是你自己写下的,它才可信。如果 client_reference_id 是 null,说明你并不知道是谁付的款;这时应该人工核对,而不是去信 untrusted_ref。客户拿错链接时会看到什么
下面每一种都会给出人话说明和下一步该做什么 —— 绝不会是空白页或一串错误码。
| 情况 | 状态码 | 客户看到的 |
|---|---|---|
| 对只认专属链接的套餐用了公开链接 | 403 | 「此链接需要专属邀请」—— 提示他向你索取专属链接。 |
| 链接打错了,或只复制了一部分 | 404 | 「这个链接打不开」—— 告诉他最常见的原因是没复制完整,请向你重新索取完整链接。 |
| 专属链接已被使用,或已过期 | 410 | 「此邀请链接已失效」—— 提示他向你重新索取一条新的。 |
升级与降级套餐
顾客换套餐时,按剩余天数折算余额抵扣新套餐首期。整个过程是一笔原子交易:关掉旧订阅 + 建立新订阅,顾客只签一次名。
已存在的订阅条款任何人都改不了,包括我们。代价就是升降级必须换地址。第一步:算折算并构造交易
| new_plan_id | string | 目标套餐 ID。 |
| owner | string | 顾客钱包地址。 |
后端按「本期实付 × 剩余秒 ÷ 周期秒」向下取整算出抵扣额 credit_atomic。升级时首期补差价;降级时若抵扣大于新套餐全价,会折算成若干期免费(promo_cycles),余数变成一期过渡价。
{
"chain": "base",
"direction": "upgrade",
"new_vault": "0xNewVault...",
"new_plan_id": "0f4c8e2a-...",
"new_nonce": 2,
"credit_atomic": "18300000",
"first_amount_atomic": "80700000",
"promo_amount_atomic": "80700000",
"promo_cycles": 1,
"promo_transition_atomic": "0",
"to": "0xGateway...",
"data": "0x...",
"value_wei": null
}对不上就直接 revert。所以即使我们算错或作弊,链上也不会执行——这是零信任设计,你不需要相信我们的算术。第二步:签名广播后确认
顾客签名广播后回填。新订阅先落 confirming 状态,等链上确认新 vault 建立后才转 active。
| new_plan_id | string | 目标套餐 ID。 |
| owner | string | 顾客钱包地址。必填——它同时是鉴权归属断言的输入,不能省略。 |
| migrate_tx | string | 迁移交易哈希。 |
| new_vault / new_nonce | string / number | 第一步返回的新 vault 地址和 nonce,原样回传。 |
| promo_* (3) | string / number | 第一步返回的三个 promo 字段,原样回传。 |
| message / signature | string | 顾客钱包签名,用于证明身份。message 必须包含 owner 地址。 |
| 必须同链同币 | 400 | 换链或换币种请让顾客新建订阅,无法迁移(vault 派生要求)。 |
| 旧订阅须在活跃态 | 409 | active / past_due / confirming 可迁移;已取消或已过期的不行。 |
| 目标套餐须启用 | 410 | 已停用的套餐不能作为迁移目标。 |
| 分销归属延续 | — | 分销商带来的订阅升级后仍归属他;但分账收款人按新套餐配置走,不延续。 |
顾客自助管理
这三个接口给顾客侧界面用,都只返回未签名交易,最终要顾客自己的钱包签名——合约会校验只有 owner 本人能执行。
| POST …/recharge-prepare | from, amount_atomic | 往 vault 充值,让订阅能继续扣款。谁都可以帮忙充。 |
| POST …/withdraw-prepare | owner, amount_atomic | 取回 vault 里的余额。传 "0" 表示全额提取。 |
| POST …/cancel-prepare | owner | 取消订阅,keeper 停止扣款。余额仍可单独取回。 |
amount_atomic 只有显式的 "0" 或空串才是全额提取。传 "abc"、科学计数法或带空格的串会返回 400 而不是静默全提——这是刻意的,避免前端传参 bug 把顾客的 vault 清空。换套餐白名单(必设,否则顾客换不了)
默认没有任何套餐允许互换。用请求体里的 upgrade_to_plan_ids 数组指定「订了这个套餐的顾客能换到哪些套餐」;只有同链同币的套餐能互换。不设白名单,顾客中心的「更换套餐」永远是空列表——看起来就像功能坏了。
在你的网站放管理订阅入口
顾客在 finality.money/account 自助管理订阅——充值、取回、取消、升降级,全部由他自己的钱包签名完成。你不用开发也不用托管,只需要给出一个链接。少了这个入口,每一次改套餐、每一次退订都会变成一封发给你的客服邮件。
最简形式
放一个普通链接即可。顾客连上付款时用的钱包,就能看到这个钱包下的全部订阅——包括在其他商户办的。不需要配置密钥、令牌或回跳地址。
<!-- 顾客全部自助完成,无需你开发 -->
<a href="https://finality.money/account">管理订阅</a>直达某一条订阅
加上 ?sub=<subscription_id>,登录后页面会自动滚动到那条订阅并高亮。适合放在用户的账户页或续费提醒邮件里——那时你已经知道他要管的是哪一条。
<!-- 登录后直接定位到这条订阅 -->
<a href="https://finality.money/account?sub=7b1e9f04-...">管理订阅</a>?sub= 也始终是安全的。subscription_id 从哪来
两个来源,通常你已经在读了:
| 每条订阅事件 | webhook | 每条订阅 webhook 的顶层都带 subscription_id,与 client_reference_id 并列。 |
| GET /v1/subscriptions | API | 列出你的订阅,编号与 webhook 里的一致。适合给存编号之前就已办卡的老用户补数据。 |
subscription_id 随之变化——也就是说,存下来的编号恰好会在你最有价值的那批升级用户身上失效。两条规则可以避开这个坑:你自己的记录以 client_reference_id(你的用户 ID,迁移时自动继承)为准;收到 subscription.migrated 或 subscription.active 时刷新存下的编号。完整示例
收到 webhook 时把编号存下来,再渲染链接。没有存到编号时回退到不带参数的 /account,可以保证链接永远可用。
// 1) 收到 webhook 时存下订阅编号
app.post("/webhooks/finality", express.json(), (req, res) => {
const evt = req.body;
const userId = evt.client_reference_id; // 办卡时你传进来的用户 ID
switch (evt.event) {
case "subscription.active":
case "subscription.migrated": // 换过套餐: 编号已变, 覆盖存新的
db.users.update(userId, { finalitySubId: evt.subscription_id });
break;
}
res.sendStatus(200);
});
// 2) 在你的账户页渲染管理链接
function manageUrl(user) {
const id = user.finalitySubId;
return id
? `https://finality.money/account?sub=${id}`
: "https://finality.money/account"; // 没存到编号: 不带参数的链接同样可用
}程序化访问(钱包签名 API,无需 API key)
顾客和分销商没有也不需要 API key——钱包签名即身份:按下面的固定模板构造消息、用钱包私钥签名,任何 bot / 脚本都能调用。不存在「谁都能查」:没有对应私钥就签不出有效会话。签名 7 天内可重复使用(只读)。
Sign in to Finality
Wallet: <WALLET_ADDRESS>
Nonce: <32_HEX_RANDOM>
Issued At: <ISO8601_UTC_NOW>
This request will not trigger a transaction or cost any gas.| POST /v1/account/subscriptions | customer | 该钱包名下全部订阅(跨商户),每条附实时余额与健康度。 |
| POST /v1/account/subscriptions/:id/close-prepare | customer | 一键关闭:构造「停止扣款 + 全额取回 + 退押金」的未签名交易(仅 Solana)。 |
| POST /v1/affiliate-portal/me | affiliate | 该钱包名下全部分销身份(跨商户):专属链接 + 带单业绩。 |
curl -X POST https://finality.money/v1/account/subscriptions \
-H "Content-Type: application/json" \
-d '{
"owner": "<WALLET_ADDRESS>",
"message": "<SIGNED_MESSAGE_ABOVE>",
"signature": "<BASE58_OR_HEX_SIGNATURE>"
}'
# → 返回 owner 与 subscriptions 数组WebSocket 实时推送
Webhook 是服务端到服务端的可靠通道,用来发货;WebSocket 是给前端界面用的,让顾客付完立刻看到「已到账」而不是转圈等轮询。wss://finality.money。
单笔收款到账推送
必须带 intent_id。缺参数时连接仍会建立,但会先收到一条 {"type":"error"} 然后被关闭。
const ws = new WebSocket(
"wss://finality.money/v1/ws?intent_id=" + intentId
);
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === "error") { console.error(m.error); return; }
// 本笔到账 (不是累计值)
console.log(m.chain, m.token, m.amount_atomic, m.sender, m.tx_hash);
// ★ 判断是否付够要另外查一次收银台端点
fetch(`/v1/checkout/${intentId}`)
.then(r => r.json())
.then(v => { if (!v.is_partial && v.remaining_atomic === "0") fulfill(); });
};| type | "payment" | 恒为 "payment"。错误消息的 type 是 "error"。 |
| chain / token | string | 本笔到账的链与币种。 |
| address | string | 收款地址。 |
| sender | string|null | 付款方地址,从链上解析。可能为 null。 |
| amount_atomic | number | ★ 本笔到账的原子量,不是累计值。少付时收到的是这一笔的金额。 |
| tx_hash | string | 链上交易哈希。 |
type:"payment" 消息。要判断是否付够,收到推送后再查一次 GET /v1/checkout/:id 看 is_partial 和 remaining_atomic。另外沙盒的模拟支付不走这个通道。订阅确认推送
必须带 sub(订阅 id)。这是一次性连接:推完终态就主动关闭。连上瞬间会先查一次数据库,如果订阅已经是终态就立刻推一条再关——这样即使链上确认发生在你建连之前也不会错过。
{ "type": "subscription",
"subscription_id": "3d9f...",
"status": "active", // active 或 failed
"subscribe_tx": "0xabc..." }商户全局链上事件流
推送本商户名下所有链上事件,用于后台的实时动态流。鉴权方式与众不同:走 ?token=<JWT> 查询参数而不是 Authorization 头,因为浏览器的 WebSocket 握手不能带自定义头。只接受 JWT,API key 在这里不可用。缺 token 时直接返回 HTTP 401,不会升级连接。
{ "kind": "payment_settled", // payment_settled / subscription_created / charged / canceled / withdrawn / vault_topup
"chain": "base",
"subject": "0xPayAddr...", // 订阅类=vault 地址, 收款类=收款地址
"actor": "0xPayer...",
"amount_atomic": 49000000, // ★ 数字 (REST 版是字符串)
"token": "USDC",
"tx_hash": "0xabc...",
"slot": 28374611 }同样的数据也有 REST 版,用于首屏加载历史。limit 范围 1–200,默认 50。注意口径差异:REST 版只返回订阅类事件(内部是 INNER JOIN 订阅表),收款类事件查不到;而 WS 版两类都推。另外 amount_atomic 在 REST 版是字符串、WS 版是数字。
验证 Webhook 签名
每个 Webhook 在 X-Finality-Signature 头携带签名,格式为 t=1760000000,v1=3a7bd3e2…。其中 t 是本次投递的 Unix 时间戳(秒),v1 是 HMAC-SHA256(secret, 时间戳 + 英文句点 + 原始 body) 的十六进制值。用你的 Webhook 密钥校验,确认请求真实来自 Finality,防止伪造。
import crypto from "crypto";
// Header format: "t=<unix seconds>,v1=<hex hmac>"
// The signed message is `${t}.${rawBody}` — the timestamp is INSIDE the HMAC,
// so it can't be moved forward without the secret.
const TOLERANCE_SECONDS = 300; // 5 minutes, same default as Stripe
// Constant-time compare that never throws.
// timingSafeEqual requires equal-length buffers — it throws RangeError otherwise,
// and Buffer.from(undefined) throws TypeError when the header is missing.
// Both are trivially triggerable by an attacker, so check length first.
function verify(rawBody, signature, secret) {
if (typeof signature !== "string") return false;
// 1) Parse "t=...,v1=..." (ignore unknown vN= parts so future versions don't break this)
let t = null, v1 = null;
for (const part of signature.split(",")) {
const [k, val] = part.trim().split("=");
if (k === "t") t = Number(val);
else if (k === "v1") v1 = val;
}
if (!Number.isFinite(t) || !v1) return false;
// 2) Reject anything outside the tolerance window — THIS is what stops replays.
// Without it a captured request stays valid forever.
// Math.abs also rejects timestamps from the future.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
// 3) Verify the HMAC over "${t}.${rawBody}"
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]);
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/finality", express.raw({ type: "*/*" }), (req, res) => {
// req.body is a raw Buffer here — never JSON.parse before verifying.
if (!verify(req.body, req.headers["x-finality-signature"], process.env.FINALITY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
// Deduplicate: the same event can be delivered more than once.
const eventId = req.headers["x-finality-event-id"];
if (await alreadyProcessed(eventId)) return res.sendStatus(200);
await markProcessed(eventId);
const evt = JSON.parse(req.body);
if (evt.event === "payment.settled") {
await fulfillOrder(evt.merchant_order_id); // funds are in your wallet — ship it
}
res.sendStatus(200);
});import hmac, hashlib, time
TOLERANCE_SECONDS = 300 # 5 minutes, same default as Stripe
@app.post("/webhooks/finality")
def finality_webhook():
# Header format: "t=<unix seconds>,v1=<hex hmac>"; signed message is "{t}.{raw_body}"
header = request.headers.get("X-Finality-Signature", "")
parts = dict(
p.strip().split("=", 1) for p in header.split(",") if "=" in p
)
try:
ts = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
abort(401)
# Reject anything outside the tolerance window — this is what stops replays.
# abs() also rejects timestamps from the future.
if abs(time.time() - ts) > TOLERANCE_SECONDS:
abort(401)
signed_payload = f"{ts}.".encode() + request.data # raw bytes, never re-serialized
expected = hmac.new(
WEBHOOK_SECRET.encode(), signed_payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
evt = request.get_json()
if evt["event"] == "payment.settled":
fulfill_order(evt["merchant_order_id"])
return "", 200<?php
// Header format: "t=<unix seconds>,v1=<hex hmac>"; signed message is "{t}.{raw_body}"
const TOLERANCE_SECONDS = 300; // 5 minutes, same default as Stripe
function finality_verify(string $rawBody, ?string $header, string $secret): bool {
if ($header === null) return false;
$t = null; $v1 = null;
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = $kv[1];
elseif ($kv[0] === 'v1') $v1 = $kv[1];
}
if ($t === null || $v1 === null || !ctype_digit($t)) return false;
// Reject anything outside the tolerance window — this is what stops replays.
if (abs(time() - (int)$t) > TOLERANCE_SECONDS) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1); // constant-time
}
// php://input is the raw body — never json_encode(json_decode(...)) before hashing.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_FINALITY_SIGNATURE'] ?? null;
if (!finality_verify($rawBody, $header, getenv('FINALITY_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$evt = json_decode($rawBody, true);
if ($evt['event'] === 'payment.settled') {
fulfill_order($evt['merchant_order_id']); // funds are in your wallet — ship it
}
http_response_code(200);payment.settled 投递,就能把那串字节永久重放且签名照样验得过,你每次都会重复发货。请拒绝超出容差窗口的请求(建议 5 分钟,与 Stripe 一致),让截获到的请求很快失效。同时继续用 X-Finality-Event-Id 去重:时间戳限定重放窗口,事件 ID 挡住窗口内的重复。JSON.parse 再 JSON.stringify 后计算——重新序列化会改变空格 / 字段顺序 / 转义,字节一变签名就对不上,验签必然失败。上面示例用 express.raw / request.data 拿的正是未经解析的原始字节,验签通过后再解析成对象。支付二维码与深链
finality 是精确金额模型:收到的钱必须 ≥ 应付金额才会结算。所以二维码里必须带上金额——只编码一个裸地址,顾客手填差一分钱,这单就会一直卡在待付直到过期。
49.00),EIP-681 用原子量(49000000)。写反的后果是顾客付出 100 万倍或百万分之一的钱。Solana Pay
格式 solana:<收款地址>?amount=<小数>。带 spl-token 参数则转代币(钱包会自动派生 ATA),不带则转原生 SOL。
# 原生 SOL (无 spl-token 参数)
solana:9xQe...Recipient?amount=1.25&label=Acme&message=Order%201234
# SPL 代币 (USDC): amount 仍是小数, 不是原子量
solana:9xQe...Recipient?amount=49.00\
&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\
&label=Acme&message=Order%201234amount=49.00 而不是 49000000。我们用的是非交互式 Transfer Request——二维码自带全部信息,钱包无需回调服务器。EIP-681(EVM 各链)
原生币和 ERC20 是两种不同形态:ERC20 时路径上放的是代币合约地址,真正的收款人挪到 query 的 address 参数里。
# 原生币 (ETH/BNB): value = wei 原子量
ethereum:0xRecipient...@8453?value=1000000000000000
# ERC20 (USDC on Base, 6 位小数): 49.00 USDC = 49000000
# ★ 路径上是「代币合约地址」, 收款人挪到 query 的 address
ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer\
?address=0xRecipient...&uint256=49000000| 链 ID | @<chainId> | 紧跟地址后,十进制。Base = 8453,BSC = 56。 |
| 原生币金额 | value | wei 原子量。0.001 ETH = 1000000000000000。 |
| ERC20 金额 | uint256 | 代币最小单位。USDC 是 6 位小数,49.00 = 49000000。 |
分账 / 联盟返佣
一笔收款可同时分给多个受益方 —— 卖家、平台、推广者,在同一笔链上交易里原子完成。适合 marketplace、联盟返佣、多方结算。任何一方失败,整笔回滚。
{
"chain": "base",
"token": "USDC",
"amount": "1000.00",
"merchant_order_id": "mkt-order-2001",
"splits": [
{ "address": "0xSeller...", "bps": 8900, "label": "seller" },
{ "address": "0xPlatform...", "bps": 700, "label": "platform" },
{ "address": "0xAffiliate...", "bps": 300, "label": "affiliate" }
]
}bps 为万分比,各受益方之和 + 平台费(100 bps)= 10000。分账规则编码进收款地址,链上强制执行,任何人都改不了流向。
分销 / Affiliate
让别人帮你卖,自动给他分钱。为每个分销商生成一条专属链接,顾客从他的链接下单 / 订阅,每笔成交自动从你的收益里给他分成,链上直达他的地址——你零手动。适合联盟营销、KOL 带货、渠道分销。
为什么这不是「又一个分销功能」
传统分销平台的痛点全在「给分销商发钱」这一环:月底才结算、拖 30-60 天才到账、要人工核对账目、用 PayPal 或银行转账打款(很多国家的分销商根本收不了)——分销商还要担心「商户到底会不会付」。
finality 把这一环整个去掉了:佣金不经你的手。顾客付款的同一笔交易里,分销商的那份自动拆分、直达他自己的钱包——几秒到账、不用等账期、不用对账,每一笔都能在区块链上查到。想赖账?做不到。
银行卡付款会被「拒付」追回,所以传统平台要押着佣金等 1-2 个月;加密货币付款没有拒付,佣金也就没有「追回」这回事:规则一句话——成交即结算,永不回头。对你和分销商都一样确定。想保留弹性激励?把链上分成设低一点,业绩好再另发奖金。
和「分账 splits」的区别
| 分账 splits | splits | 每一单固定分给这几个人(静态联合分成)。适合平台 + 区域代理 + 门店这类固定渠道链路。上限 5 个受益方。 |
| 分销 affiliate | affiliate | 谁带单钱就只给谁(动态归因)。适合分销商团队 / 联盟——每人一条专属链接,顾客从谁的链接来钱就分给谁。分销商数量无上限。 |
填分销商备注名 + 多链收款地址。之后可为他建多条链接(挂不同计划)。
为他选一个订阅计划、设分成比例,一键生成带 ?aff= 的专属链接。
把链接给分销商推广。后台按分销商聚合:谁带来多少订阅、多少成交额,链上真实成交、无法造假。
30 天归因:顾客点了分销商的链接、当时没买,30 天内回到同一套餐下单(哪怕不是从链接进来的),这单仍然算这个分销商带来的。以最后点击的链接为准。
| GET /v1/affiliate-portal/invite/:token | public | 邀请预览:分销商打开邀请链接时看到的商户名与当前地址(token 即凭证)。 |
| POST /v1/affiliate-portal/me | public | 分销商钱包签名登录:名下全部分销身份 + 链接 + 业绩(签名格式见「顾客自助管理」一节)。 |
| POST /v1/pay-link/:code | public | exact 分销链接落地建单:按链接模板自动建单并注入分成,返回订单视图。 |
① 注册分销商
先注册一个分销商,拿到 affiliate id。收款地址存一次,之后所有链接复用。
| name | string | 分销商备注名(商户内可读,如「KOL-张三」) |
| addresses | object | 多链收款地址对象,如 { "solana": "...", "base": "0x..." }。填哪条链就在哪条链分账 |
| external_ref | string? | 你 affiliate 系统里的 ID(可选,便于双向对账) |
curl -X POST https://finality.money/v1/affiliates \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Influencer Jane",
"addresses": { "solana": "7xKq...3fBw", "base": "0xJane..." },
"external_ref": "your_affiliate_id_42"
}'
# → { "id": "b7c1e2d4-...", "ref_code": "...", ... }② 建专属链接
为分销商在某个订阅计划上建一条专属链接,设定他的分成比例。返回带 ?aff= 的完整链接,直接甩给分销商推广。
| kind | string | "subscription"(挂订阅计划)或 "exact"(挂一次性收款) |
| plan_id | string? | 订阅计划 ID(kind=subscription 时必填) |
| bps | number | 分给分销商的比例(bps 万分比;2000=20%)。从你的 99% 里切,1..=9900 |
curl -X POST https://finality.money/v1/affiliates/<affiliate_id>/links \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "kind": "subscription", "plan_id": "0f4c8e2a-...", "bps": 2000 }'
# → 返回专属链接, 直接甩给分销商去推广:
{
"ref_code": "ABC23456",
"bps": 2000,
"url": "https://finality.money/subscribe/0f4c8e2a-...?aff=ABC23456"
}?aff= 是分销商专属码(决定给谁分钱);?ref= 是你系统里的用户 ID(client_reference_id,用于对账)。一条链接可同时带两者:?aff=ABC23456&ref=user_8837。③ 看报表
查全部分销商 + 归因报表:每个分销商带来的订阅数、订单数、累计成交额。数据来自链上真实成交(非点击埋点),无法造假。
顾客多付了怎么办
精确金额模型下,顾客多付的部分会留在那个收款地址里。这个接口生成一笔提取交易,把超额部分取走——同样由你自己签名。
| intent_id | string | 对应的收款单 ID。 |
curl -X POST https://finality.money/v1/merchant/overpaid/prepare \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "intent_id": "6f1c9e2a-..." }'
# 响应是「扁平」结构 (交易字段直接在顶层, 没有 tx 包裹层)
# EVM: { "chain","to","data","from","is_native": false }
# Solana: { "chain","solana_unsigned_tx","from" }
# 同样由你自己签名广播, 平台不碰钱recipients[0](也就是你配置的商户收款地址)有权提取。这是写在合约里的,任何人都改不了,包括我们。发票(收款收据)
订单结算后自动生成发票号,带链上交易哈希作为不可篡改的付款铁证。这是「已付款收据」而不是「待付账单」——待付的那份就是订单本身。
| invoice_no | string | 格式 INV-{年月}-{6 位序号}, 结算时自动生成。 |
| issued_at | string | 开具时间 = 结算时间。 |
| merchant | object | 你的抬头信息:name、email、logo_url、website。 |
| order | object | 订单信息:intent_id、你的订单号、创建时间。 |
| payment | object | 付款详情:链、币种、金额、结算 tx、付款方地址、分账明细。 |
{
"invoice_no": "INV-202608-000731",
"issued_at": "2026-08-07T09:14:02Z",
"merchant": { "name": "Acme", "email": "...", "logo_url": null, "website": null },
"order": { "intent_id": "6f1c...", "merchant_order_id": "order-8801",
"created_at": "2026-08-07T09:11:40Z" },
"payment": { "chain": "base", "token": "USDC",
"amount_usd": "49.00", "token_amount": "49.00",
"settle_tx": "0xabc...",
"payer": "0xCustomer...",
"merchant_amount": "48.51", "protocol_fee": "0.49",
"splits_detail": [ ... ] }
}409:订单尚未结算(还没产生付款事实),或这是沙盒订单(sk_test_ 建的单不出正式发票号)。payer 和 token_amount 可能为 null。公开收据(顾客凭证,无鉴权)
同一份发票数据的公开子集:知道订单 UUID 即可查看(UUID 不可枚举,同 Stripe 收据链接模型),不含你的成本结构(实收、手续费、分账明细都只在商户版里)。顾客付款成功页会自动拿到收据链接 /invoice/:intent_id;售后对账让顾客甩一个链接就说清一单。
收款分析
按链、按币种的收款分布,以及两个维度的客户排行。用来回答「哪条链的顾客最多」「谁是回头客」。
| period | string? | today / 7d / 30d。传别的值或不传 = 全部时间。没有 limit 参数,客户榜固定返回前 50。 |
| by_chain | array | 按链聚合: {key, count, usd}。 |
| by_token | array | 按币种聚合: {key, count, usd}。 |
| top_customers | array | 按你透传的 client_reference_id 聚合。 |
| top_payers | array | 按链上付款地址聚合,带 returning 回头客标记。 |
| daily_trend | array | 近 30 天每日 {day, views, paid}。 |
top_customers 靠你在建单时透传 metadata.client_reference_id——没传就完全看不到;top_payers 是从链上自动采集的付款地址,永远都有,即使你什么都没传。想做客户分析但没接会员系统,直接看 top_payers。curl "https://finality.money/v1/merchant/analytics?period=30d" \
-H "Authorization: Bearer sk_live_..."
# top_payers 按 (付款地址, 链) 聚合, returning=true 表示回头客
# { "top_payers": [
# { "address": "0xCustomer...", "chain": "base", "orders": 4,
# "usd": "196.00", "returning": true,
# "first_at": "...", "last_at": "..." } ] }by_chain/by_token/top_payers 都已排除沙盒单,但 top_customers 没有排除。daily_trend 统计的是订阅链接的漏斗数据,不是一次性收款。数据库出错时会静默返回空数组而不是报错。API 密钥与权限范围
服务端调用用 API key,密钥分三种权限范围。请如实理解每种范围的实际边界——下面写的是代码里真正的行为,不是理想中的行为。
| label | string? | 备注名,方便你区分用途。默认 "key"。 |
| scope | string? | full / payments_only / read_only。默认 full。 |
| is_test | boolean? | true 生成沙盒密钥(sk_test_ 前缀),默认 false。 |
| distribution | array? | 分销策略数组,可选。 |
三种权限范围
| full | 全部 | 可建单、可提取收款地址中未结算的余额。给你自己的服务端后台用。 |
| payments_only | 仅收款 | 可建单和读取,但不能提取余额。给下游系统或第三方用。 |
| read_only | 只读 | 只能读,不能建单。给数据看板、对账脚本用。 |
payments_only 与 full 在建单能力上完全相同,两者的唯一差别是余额提取与重试结算这类资金类写操作。如果你的目的是「限制某个系统只能收款不能动钱」,那 payments_only 是对的;如果你以为它连建单也受限,那是误解。密钥管理
| GET /v1/merchant/keys | 列出密钥 | 返回未吊销的密钥。响应里不含 scope 和 is_test 字段,只能从 pk_test_ / pk_live_ 前缀判断环境。 |
| PATCH /v1/merchant/keys/:id | 改备注 / 分销策略 | 注意路径带 :id。无法修改 scope——要换权限范围只能吊销后重建。 |
| DELETE /v1/merchant/keys/:id | 吊销密钥 | 软删除,立即失效。路径必须带 :id。 |
distribution 字段不是「保持不变」,而是清空(它没有 Optional 包装,不传等于传空数组)。要改备注又想保留分销策略,必须把原策略一起传回去。label 则相反,不传保持原值。sk_test_ 建的单是沙盒单(webhook 里 livemode: false),sk_live_ 是真实收款。环境完全由密钥决定,请求体里声明无效。团队与角色
邀请同事共管账户,按角色分权。以下是三个角色的实际权限边界。
| owner | 所有者 | 账户本人。改收款地址只有 owner 能做,且需二次验证。 |
| admin | 管理员 | 可读可写:建单、提取余额、管密钥、管团队。 |
| viewer | 只读成员 | 只能查看,任何写操作都返回 403。 |
owner 与 admin 的权限完全等价,唯一实际生效的分界是「是不是 viewer」。另外无法通过 API 授予 owner 角色——传 "owner" 会被静默降为 viewer。请按这个现状规划你的团队分权,不要依赖 admin 弱于 owner 的假设。邀请流程
- 1你(owner 或 admin)调邀请接口,拿到一条带 token 的邀请链接。
- 2把链接发给同事——平台不会自动发邮件,需要你自己转达。
- 3同事先自行注册或登录 finality,拿到自己的账号。
- 4同事带着 token 调接受接口,成员状态变为 active。
- 5同事退出后重新登录——这一步不能省,见下方说明。
# ① 邀请 (owner/admin, JWT 鉴权)
curl -X POST https://finality.money/v1/merchant/team \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "role": "viewer" }'
# → { "member_id": "...", "role": "viewer",
# "invite_url": "https://finality.money/team/accept?token=inv_..." }
# ★ token 只在这里返回一次, 之后任何端点都取不回来
# ② 被邀请人自己注册/登录后, 带 token 接受
curl -X POST https://finality.money/v1/merchant/team/accept \
-H "Authorization: Bearer <被邀请人的 JWT>" \
-d '{ "token": "inv_..." }'审计日志
记录账户上的关键写操作:谁、在什么时间、从哪个 IP、做了什么。用 limit 控制条数(1–500,默认 100),按时间倒序。
| actor | string|null | 操作人的商户 ID。组织成员操作时,这里是成员本人的 ID。 |
| action | string | 动作类型,见下表。 |
| target | string|null | 被操作对象的 ID(如密钥 ID、成员 ID)。 |
| detail | object | 动作相关的结构化细节,各动作字段不同。 |
| ip | string|null | 来源 IP。 |
| created_at | string | 发生时间。 |
记录的动作类型
auth.login 2fa.enable 2fa.disable
2fa.recovery_used api_key.create api_key.patch
api_key.delete webhook.rotate_secret
config.update notify_channels.update
payout_address.update refund.prepare refund.confirm
team.invite team.set_role team.removelimit 一个参数——没有按动作类型、操作人或时间范围筛选,也没有翻页游标。需要长期留存或复杂检索,请定期拉取后存到自己的日志系统。两步验证(2FA)
基于 TOTP 的两步验证,兼容 Google Authenticator、1Password、Authy 等标准验证器。开启后,所有登录方式(邮箱、Google、钱包签名)都会要求验证码。
开启流程(两步)
- 1调 enable 生成密钥,拿到 otpauth 链接——此时还没生效。
- 2把 otpauth 链接编成二维码,让用户用验证器 App 扫描。
- 3用户输入 App 显示的 6 位数字,调 confirm 确认。
- 4确认成功后返回 10 个恢复码,务必让用户当场保存。
# ① 生成 secret (此时还没生效)
POST /v1/merchant/2fa/enable (无 body)
# → { "secret": "JBSWY3DP...", "otpauth_uri": "otpauth://totp/Finality:..." }
# 把 otpauth_uri 编成二维码给用户扫; 没有现成 QR 图片
# ② 输入 App 上的 6 位码确认, 此时才真正开启
POST /v1/merchant/2fa/confirm { "code": "123456" }
# → { "enabled": true, "recovery_codes": [ 10 个 8 位码 ] }
# ★ 恢复码只返回这一次, 没有补发端点totp_enabled 仍然是 false。如果用户扫了码就关掉页面,下次登录仍然不会要验证码——界面上要把「必须输入验证码确认」这一步做成不可跳过。登录时的两步流程
开启 2FA 后,登录接口不再直接返回正式 token,而是返回一个 challenge。前端据此弹出验证码框,用验证码换取正式登录态。
// 开了 2FA 时, 登录接口不发正式 token, 改发 challenge:
{ "merchant_id": "...", "need_2fa": true, "challenge": "<5 分钟有效>" }
// 前端弹码框, 再换正式 JWT:
POST /v1/auth/2fa { "challenge": "...", "code": "123456" }
// → { "merchant_id": "...", "token": "<30 天 JWT>", "role": "owner" }
// 未开 2FA 时, 登录直接返回:
{ "merchant_id": "...", "token": "...", "role": "owner" }它不能当作业务凭证使用,拿它去调任何业务接口都会被拒。正式登录态有效期 30 天。端点速查
前面各节没有单独展开、但确实可用的端点,在这里一次列全。完整机器可读版本见 /openapi.json。
工具类
| GET /v1/prices | 无需鉴权 | 原生币实时报价(SOL/ETH/BNB/BTC)+ Solana 的 rent 常量。按法币定价时用来估算币量。 |
| GET /v1/wallet-balances | 无需鉴权 | 查任意地址在某链某币的余额。三个参数(chain/address/token)缺一不可。付款前预检顾客余额和 gas。 |
| GET /health | 无需鉴权 | 存活探针,用于监控和负载均衡。 |
# 实时币价 + Solana rent 常量
curl https://finality.money/v1/prices
# { "sol":73.95, "eth":1918.86, "bnb":591.70, "btc":64792.59,
# "solana_ata_rent_sol":0.00148844, "solana_account_rent_sol":0.00065024,
# "solana_ata_rent_lamports":1488440, "solana_account_rent_lamports":650240 }
# rent figures are read live from the network — do not hardcode them
# 查余额 (三个参数缺一不可)
curl "https://finality.money/v1/wallet-balances\
?chain=base&address=0xCustomer...&token=USDC"订阅相关
| GET /v1/subscriptions/overview | API key / JWT | 订阅总览: MRR、活跃/风险订阅数、每个计划下每位订阅者的 vault 余额与还够扣几期。后台首屏用这一个请求就够, 避免 N+1。 |
| GET /v1/subscriptions | API key / JWT | 列出订阅,每条附 vault 余额和还够扣几期。可用 ?client_reference_id= 精确查某个用户的订阅,用 ?limit= 加返回的 next_cursor(cursor_created_at + cursor_id)翻页。传了其中任一参数时,返回 {data, has_more, next_cursor} 而不是裸数组。 |
| GET /v1/subscriptions/:id | API key / JWT | 查单条订阅详情。 |
| GET /v1/subscriptions/plans | API key / JWT | 列出全部订阅计划。 |
| PATCH /v1/subscriptions/plans/:id | API key / JWT | 停用/启用计划。停用后链接失效、不接新订阅,但已有订阅继续正常扣款。 |
| GET /v1/subscriptions/plans/:id/insights | API key / JWT | 单个计划的深度洞察:分步流失漏斗、弃单原因、钱包/链/币种分布、每日趋势。 |
| GET /v1/subscriptions/sessions | API key / JWT | 列出一次性邀请及其 open/completed/expired 转化情况。 |
| POST /v1/account/subscriptions | 无需鉴权 | 顾客侧用户中心:用钱包签名换取该地址名下所有订阅(跨商户),顾客无需注册账号。 |
POST /v1/account/subscriptions 是给顾客用的,不是给商户用的。顾客用钱包签个名就能看到自己在所有商户处的订阅并自助管理(充值/取消/取回余额),完全不需要在任何一家注册账号——这是非托管模型才做得到的体验。优惠券与分销(含归档操作)
| GET /v1/coupons | API key / JWT | 列出优惠券。 |
| PATCH /v1/coupons/:id | API key / JWT | 停用/启用优惠券。 |
| PATCH /v1/affiliates/:id | API key / JWT | 停用/启用分销商。 |
| PATCH /v1/affiliate-links/:id | API key / JWT | 停用/启用单条分销链接。 |
商户配置
注意是 PATCH 而不是 POST/PUT——用错方法会得到 405。传了哪些字段就改哪些,没传的保持原值。
| payout_addresses | object | 各链收款地址,形如 {"solana":"...","base":"0x..."}。合并语义:没传的链不受影响。 |
| webhook_url / logo_url | string | Webhook 地址、商户 logo。 |
| default_success_url | string | 全局默认的付款成功回跳地址。 |
| email_code / wallet_sig | string | 改收款地址时的二次验证凭据,见下方说明。 |
wallet_sig + wallet_nonce),邮箱账户要邮件验证码(先调 verify/send 拿码,再传 email_code)。这比 2FA 更强,因为攻击者即使拿到你的验证器也过不了这关。另注意本接口非原子:其他字段会先写入,之后二次验证失败只回滚收款地址部分。| GET /v1/merchant/config | API key / JWT | 读取商户全量配置(含 webhook 密钥明文、各链收款地址、通知渠道)。 |
| GET /v1/merchant/2fa/status | JWT | 查当前账号是否已开启两步验证。 |
| POST /v1/merchant/2fa/disable | JWT | 关闭两步验证。只接受 TOTP 验证码, 不接受恢复码。 |
| POST /v1/merchant/verify/send | JWT | 发送改收款地址所需的邮箱验证码。邮箱账户专用。 |
| GET /v1/merchant/referral | JWT | 平台返佣:你的推荐码、推荐链接、已推荐商户数与累计返佣。 |
| GET /v1/merchant/referral/earnings | JWT | 返佣明细。 |
| POST /v1/merchant/notify/test | JWT | 向已配置的 Telegram / Discord / Slack / 飞书 / Webhook 各发一条测试通知,逐渠道返回结果。 |
/v1/auth/*)—— 由我们的后台与 SDK 内部使用,商户不需要自己实现登录;②订阅收银台内部端点(/v1/sub-checkout/*)—— 托管收银台自用,你只需要拿 checkout_url;③线下演示台(/v1/kiosk/*)—— 展会演示专用。它们都真实存在且稳定,只是不属于商户集成路径。支持的链
| 网络 | 币种 | 状态 |
|---|---|---|
| Solana | SOL · USDC · USDT · cbBTC | 已上线 |
| Base | ETH · USDC · USDT · cbBTC | 已上线 |
| BNB Chain | BNB · USDT · USDC | 已上线 |
| Ethereum | ETH · USDT · USDC · DAI · PYUSD · cbBTC | 已上线 |
| Robinhood Chain | ETH · USDG | 已上线 |
客户在哪条链付款由你创建收款时指定。同一订单可创建多条链的收款让客户选。
Solana 收款准备
在 Solana 上,收款地址本身需要持有一点点余额才能在网络上存在。你只需要保证收款地址不是全新的空地址——充入 0.001 SOL 即可,一次就好。其余的事系统都替你处理了,不需要你额外操作、也不向你收费。Base、BNB Chain 等其他链完全没有这个要求。
确切金额(由 Solana 设定,非我们)
这个门槛由 Solana 网络固定,Finality 一分不加:
- 全新地址第一次收原生 SOL:Solana 要求地址持有一小笔最低余额才能在链上存在(具体数额由网络设定,会随时间调整)。充 0.001 SOL 就稳妥了。
- 收 USDC / USDT:你无需任何准备,系统会自动处理。
你可以阅读 Solana 官方对账户与租金的解释: Solana 官方文档 · 账户与租金 ↗
谁需要准备,怎么准备
往你的 Solana 收款地址充入 0.001 SOL,一次就好。其余的事系统都替你处理。
如果你配了分销商分账,请每位分销商往自己地址转一笔任意金额的 USDC(或 USDT)——哪怕 0.01 美元,收到就自动生效。没准备好也不影响你出单,只是他那一份会归你。
做过一次就再也不用管了。
常见问题
只有一件事:往你的 Solana 收款地址充 0.001 SOL。不做也基本没问题——绝大多数订单的到账金额本身就够让地址生效,只有极小额的 SOL 订单会被拦下,届时创建订单时会有明确提示。
归你。链上在分账时会逐个判断每个收款方能不能收,收不了的直接跳过,他那一份留给你(商户)。我们不会替任何分销商垫付这笔账户费用,也不会从谁的份额里扣钱——不存在被扣走的钱,只有跳过或不跳过。
要——分销商需要自己准备。请每个分销商往自己地址转一次任意金额的 USDC/USDT(哪怕 0.01 美元);如果分给他的是原生 SOL,他的地址有余额即可。没准备好也不影响你出单,只是他那一份会归你。
你自己的收款地址不用——收到哪种代币,系统就为哪种自动处理。只有分销商的地址需要每种代币各转一次。
本页只讲 Solana。Base、BNB Chain、Robinhood Chain 等 EVM 链的收款没有激活步骤,也没有这笔费用。
嵌入式收款(不跳转)
目前收款只有一种方式:把顾客送到我们托管的收银台页面。它的地址你已经有了——就是你创建收款或套餐时返回的 checkout_url。把「支付」或「订阅」按钮指向它就完成了,不需要安装任何东西。
cdn.finality.money 和 @finality/react 现在并不存在,照抄是跑不通的。请用上面的托管收银台链接,收款能力完全一样。<script src="https://cdn.finality.money/widget.js"></script>
<div id="pay"></div>
<script>
finality.mount('#pay', {
intentId: '9aRtUe2c-...', // 后端创建收款返回的 id (UUID)
theme: { accent:'#你的品牌色', radius:'14px' }, // 自由定制
onSettled: (e) => { location.href = '/success'; } // 付款成功回调
});
</script>import { FinalityCheckout } from '@finality/react';
<FinalityCheckout
intentId="9aRtUe2c-..."
theme={{ accent: '#你的品牌色' }}
onSettled={(e) => router.push('/success')}
/>x402 —— 让 AI Agent 直接付款
我们支持 x402 协议(基于 HTTP 402 的机器原生支付标准),AI agent 无需浏览器、无需会话、无需人点按钮就能付掉一笔订单。付款走的是**和人类顾客完全相同的那笔订单**——同一个收款地址、同一套 webhook、同一份发票。
付款流程(两次 HTTP 往返)
不带付款凭证 → 返回 402 + 报价单;带上签名 → 返回 200。报价单同时放在 PAYMENT-REQUIRED 头(base64,v2 规范要求)和响应体里(方便你用 curl 直接读)。
# ① 不带凭证 → 拿报价单
curl -i https://finality.money/v1/x402/<intent_id>
HTTP/1.1 402 Payment Required
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6Mi... # 同一份报价单的 base64
{ "x402Version": 2,
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"amount": "1000000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x58b3822ac31ed6c8c8bc2199575f4857920c5ea8",
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" } # ★ 链上读到的真值, 不是硬编码
}] }
# ② 带签名重发 → 放行
curl -i https://finality.money/v1/x402/<intent_id> \
-H "PAYMENT-SIGNATURE: <base64 PaymentPayload>"
HTTP/1.1 200 OK
PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLC...能力查询
返回当前真实可用的链与代币。这个列表不是写死的——服务端会逐个链上读代币的 name() / version() 来确认它确实支持 EIP-3009,不支持的不会出现在列表里。
| Base | eip155:8453 | USDC、cbBTC(链上已验证支持 EIP-3009) |
| Ethereum | eip155:1 | USDC、DAI、cbBTC |
| BSC / Robinhood / Solana | — | 暂不支持。BSC 的 USDC 是 Binance-Peg 包装币无 EIP-3009;Robin 的 USDG 缺 version();Solana 走完全不同的机制(部分签名交易),后续再做。 |
兼容性说明
我们是 100% 标准实现,不需要任何自定义参数。payTo 就是这笔订单在建单时派生好的专属地址,所以任何现成 x402 客户端(@x402/fetch、Python 的 x402、Go SDK)都能开箱即用。有些网关因为把钱先收到共享中转钱包,必须要求非标准的 ?payerAddress= 参数,导致现成 SDK 都要手改 URL——我们没有这个问题。
| 协议版本 | v2 + v1 | 以 v2 为准(PAYMENT-SIGNATURE / PAYMENT-RESPONSE),同时接受 v1 的 X-PAYMENT 头。 |
| Scheme | exact | 只支持 exact——与我们的精确金额模型天然一致。计量/订阅场景请用我们的订阅 vault。 |
| 金额校验 | = | 必须严格相等(v2 规范)。多付少付都会被拒,多付请走超额退还流程。 |
| 谁付 gas | agent | agent 自己广播自己付 gas,所以钱包里除了 USDC 还需要少量原生币。这是 payer-broadcast 模式的必然要求。 |
name 必须用代币合约链上的真值,不能硬编码——Base 主网 USDC 是 "USD Coin",而测试网是 "USDC"。写死任一个,另一个环境的验签会全部失败且毫无线索。我们在报价单的 extra 里已经把真值给你了,直接用即可。MCP Server(让 AI Agent 帮你接入)
我们提供官方 MCP server,让 Claude、Cursor、Windsurf 或任何支持 MCP 的 AI agent 直接读取真实 API 参考、生成正确的集成代码、建沙盒单、触发模拟 webhook 并验证投递——全程零真金。装好后你只需要说「帮我给网站加个加密收款」。
{
"mcpServers": {
"finality": {
"command": "npx",
"args": ["-y", "@finality/mcp"],
"env": { "FINALITY_API_KEY": "sk_test_..." }
}
}
}sk_test_ 沙盒密钥。提供的 12 个 tool
| finality_docs | 无需密钥 | 拉取官方文档:概览 / 完整 API 参考 / OpenAPI spec。agent 写代码前应先调这个,避免猜错字段名。 |
| finality_integration_guide | 无需密钥 | 按场景给出分步集成方案(电商插件 / 自建后端 / 支付链接 / 订阅 / 平台分账),并点明关键的坑。 |
| finality_create_payment | 需要密钥 | 建收款单。零资金风险——只是生成收款地址,钱进商户自己的钱包。 |
| finality_get_payment | 需要密钥 | 查单笔状态。 |
| finality_list_payments | 需要密钥 | 搜索与翻页查单。 |
| finality_payment_lifecycle | 需要密钥 | 单笔全链路追溯,含真实付款方地址与 webhook 投递结果。 |
| finality_simulate_payment | 需要密钥 | 沙盒模拟支付,触发真 webhook。 |
| finality_analytics | 需要密钥 | 收款分析。 |
| finality_subscriptions_overview | 需要密钥 | 订阅总览(MRR、风险订阅、vault 余额)。 |
| finality_check_webhook_health | 需要密钥 | Webhook 投递健康检查,排查「付了但系统没反应」。 |
| finality_prices | 无需密钥 | 实时币价与 Solana rent。 |
| finality_wallet_balance | 无需密钥 | 查任意钱包余额。 |
finality_integration_guide 拿方案 → 调 finality_docs 核对字段 → 写代码 → finality_create_payment 建沙盒单 → finality_simulate_payment 触发 webhook → finality_check_webhook_health 确认投递成功。整个集成在上线前就被验证过一遍,且没花一分钱。机器可读入口
如果你在训练/接入自己的 AI 系统,这三个文件可以直接抓取,内容与本文档同源:
| /llms.txt | 站点摘要 | 产品定位、定价、支持的链、能力边界。llms.txt 事实标准格式。 |
| /llms-full.txt | 完整 API 参考 | 每个端点的参数、响应、约束与常见陷阱,供 agent 生成正确代码。 |
| /openapi.json | OpenAPI 3.1 | 标准 OpenAPI 3.1,可直接喂给代码生成器或 API 工具链。 |
SDK 与插件
按你的技术栈选一条路。插件完全不需要写代码;SDK 帮你处理好签名、重试和 webhook 验签。
npm i @finality/nodepip install finality上传插件 → 设置 → 支付短代码:[finality_pay amount="49"]自托管应用——部署后安装到店铺任何语言、任何框架错误码
所有错误都会返回 HTTP 状态码 + JSON 体(含 error 字段)。请**按状态码判断**——它是稳定的;错误文案是给人看的,可能会变,不要用它做程序判断。
| 400 | 请求有误 | 请求里有问题:链或币种不认识、金额格式非法、优惠券无效,或该链还没配收款地址。具体原因看 message。 |
| 401 / 403 | 未授权 | API key 缺失、无效、已吊销——或者 key 的权限范围不够(read_only 的 key 不能建收款单)。 |
| 404 | 找不到 | 资源不存在,或者它属于别的商户。我们**刻意返回 404 而不是 403**,这样接口永远不会泄露某个 id 是否存在。 |
| 409 | 状态冲突 | 资源当前状态不允许这个操作——比如给一笔还没结算的订单要发票。 |
| 429 | 触发限流 | 退避后重试。这是**唯一**带机器可读 code 和 retry_after(秒)的响应,同时也会带 Retry-After 头。 |
| 5xx | 服务端错误 | 我们这边出了问题。可以安全地指数退避重试——我们的 SDK 已经帮你做了。 |
错误响应结构:
{ "error": "coupon invalid: expired" }
// 429 additionally carries a machine-readable code and a hint:
{ "error": "rate_limited", "message": "Too many requests", "retry_after": 30 }