搭建可测试、可对账、可自动化的支付集成,不再混淆客户付款与卖家打款。
1. 先明确各自负责什么
你的商城负责商品目录、卖家账号、购物车、配送和订单履约。Wholly Crypto 负责收银台、支付验证、卖家份额及已批准的打款。卖家记录不等于卖家登录账号;现有店铺插件不会自动拆分多商家购物车。
- 一个多商家购物车
- 一张客户账单
- 已验证的卖家份额
- 已批准的打款
客户先付款到项目钱包。你掌控私钥,并保管应付给卖家的资金。这不是客户直接向各卖家分账,也不是对卖家而言的非托管服务。
Marketplace 支持比特币主网、兼容的 EVM 原生币和标准 ERC-20 代币。卖家收到的是客户付款所用网络上的同一种资产,不会自动换成法币。可收款的 30 条链并非全部支持 Marketplace 打款。
2. 算清每笔金额
三位卖家各卖出 100 美元的商品。将项目佣金设为 4%,本例不设置店铺或卖家层级的覆盖值。
| 份额 | 总额 | 你的佣金 | 卖家实收 |
|---|---|---|---|
| 每位卖家 | $100 | $4 | $96 |
| 三位合计 | $300 | $12 | $288 |
这些是按账单锁定报价计算的等值金额,实际以加密资产支付,并不保证未来的美元价值。通常的 1% 处理费会从预付余额扣除 3 美元,针对这张 300 美元账单仅收一次,不是每位卖家各收一次。如果适用该费用,12 美元毛佣金扣费后剩 9 美元,尚未扣除网络成本。
为比特币手续费或 EVM gas 单独准备可用的原生币。费用不能消耗卖家受保护的本金。普通归集不能花费 Marketplace 收款地址里的资金。
3. 准备钱包和卖家
- 打开 Project → Marketplace → Settings,启用 Marketplace,选择店铺并设置佣金。配置期间保持打款暂停,关闭自动规则。
- 备份项目钱包和数据库。在店铺中启用所需的 BTC/EVM 支付方式,检查扫描器是否就绪,并分别补足处理费余额和原生币手续费资金。
- 添加每位卖家,将其 UUID 与商城中的卖家 ID 对应保存;external_id 可用于记录这个关联。独立核实每个打款地址,并按准确的链和网络批准。
一种收银台支付方式需要所有参与卖家都有匹配且已批准的收款地址。后续修改卖家地址不会悄悄改变已有应付款的去向。
打款验证要求正数的确认数和两个独立、兼容的服务商,即使收银台允许零确认或只配置一个扫描器也一样。
4. 接入多商家购物车
在 Settings → API access 中创建限定项目的 Marketplace 凭证。给收银台后端 marketplace.read 和 invoices.write 权限,必要时限制到相应店铺。不要给这个密钥添加地址审批或打款审批权限。
在服务器上计算价格、折扣、税费和运费,再分配到各卖家的份额。提交 1–100 位不同卖家,用十进制字符串表示正数金额;各份额总额必须与账单完全一致。不要信任浏览器传来的分账结果,也不要用浮点数计算金额。
替换下面的 API 主机名和 UUID 占位符。从服务器环境变量读取 WHOLLY_TOKEN。请求继承已设置的 4% 佣金,不需要覆盖佣金的权限。
展开 cURL、JavaScript、PHP 或 Python 请求示例
cURL
: "${WHOLLY_TOKEN:?Set WHOLLY_TOKEN to your server-side API token}"
# Keep this key and the exact body for retries; use a new key for each new operation.
curl --fail-with-body --max-time 30 \
--request POST \
--url "https://api.example.com/v1/marketplace/invoices" \
--header "Authorization: Bearer $WHOLLY_TOKEN" \
--header 'Idempotency-Key: cart-1042-marketplace-v1' \
--header 'Content-Type: application/json' \
--data-raw '{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}'JavaScript
// Node.js 18+ · run on your server, never in browser code.
const token = process.env.WHOLLY_TOKEN;
if (!token) throw new Error("Set WHOLLY_TOKEN");
// Keep this key and the exact body for retries; use a new key for each new operation.
const body = `{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}`;
const response = await fetch("https://api.example.com/v1/marketplace/invoices", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Idempotency-Key": "cart-1042-marketplace-v1",
"Content-Type": "application/json"
},
body,
redirect: "error",
signal: AbortSignal.timeout(30000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
console.log(await response.json());PHP
<?php
// PHP 8+ with the cURL extension; run on your server.
$token = getenv('WHOLLY_TOKEN');
if (!$token) { throw new RuntimeException('Set WHOLLY_TOKEN'); }
// Keep this key and the exact body for retries; use a new key for each new operation.
$body = <<<'JSON'
{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}
JSON;
$ch = curl_init("https://api.example.com/v1/marketplace/invoices");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, "Idempotency-Key: cart-1042-marketplace-v1", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) { throw new RuntimeException(curl_error($ch)); }
curl_close($ch);
if ($status < 200 || $status >= 300) { throw new RuntimeException("HTTP $status: $response"); }
print_r(json_decode($response, true, 512, JSON_THROW_ON_ERROR));Python
# Python 3 · standard library; run on your server.
import json
import os
from urllib.request import Request, build_opener, HTTPRedirectHandler
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
# Keep this key and the exact body for retries; use a new key for each new operation.
headers = {
"Idempotency-Key": "cart-1042-marketplace-v1",
"Content-Type": "application/json"
}
headers["Authorization"] = "Bearer " + os.environ["WHOLLY_TOKEN"]
body = """{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}""".encode("utf-8")
request = Request("https://api.example.com/v1/marketplace/invoices",
method="POST", headers=headers, data=body)
# Non-2xx responses raise HTTPError. Do not retry writes with a new key.
with build_opener(NoRedirect()).open(request, timeout=30) as response:
print(json.load(response))发送前保存请求体和 Idempotency-Key。超时后用相同请求体和相同密钥重试。将 data.invoice_id 关联到订单,并跳转到响应顶层的 links.checkout。绝不要把 API 密钥放进客户浏览器。
想用 SDK?从 Marketplace 示例开始:
5. 分开跟踪收款与打款
从收银台返回并不证明付款成功。使用原始请求体验证回调签名,核对时间戳和预期项目,并在确认接收前以唯一方式保存 event_id。还要做订单级去重,避免重试导致重复履约。
| 事件 | 它代表什么 |
|---|---|
invoice.settled | 客户账单已结算。履约前检查审核标记和 Marketplace 暂扣状态。 |
marketplace.allocations.available | 已验证的份额可用于打款,但不代表已有卖家收到款项。 |
marketplace.payout.confirmed | 打款已通过所需的确认检查。 |
账单 IPN 使用店铺的 IPN 密钥。Marketplace 生命周期 webhook 使用各自端点的签名密钥。请将两类接收处理分开。对账时遇到漏收或乱序事件,通过 API 读取当前账单或打款状态。
6. 先审核,再自动化
已验证的份额可用后,取消打款暂停,但保持自动规则关闭。在 Marketplace → Payouts 准备份额,审核收款人及原生币手续费和 gas 上限,然后一次性批准准确的计划。跟踪到 Paid,而不只是 Broadcast。
比特币会把选定账单的所有未付份额放入同一批次。EVM 代币打款可能要先补充 gas,再发送代币。这是多笔交易,不是全成或全败的原子分账。
小额测试成功后,为每种资产设置自动规则:最低打款额、每笔及每日本金上限、原生币手续费和 gas 预算、间隔和确认数。启用即授权自动发送,无需再次点击。余额不足或服务器转账开关关闭仍会暂停支出。
7. 处理异常情况
少付、迟付、混合支付或链重组
收银台已结算不代表卖家份额都有足够资金支持。容差不会补出缺少的资金。检查暂扣份额,等待补款、退款,或明确批准金额更小但资金充足的分配。多付款不会自动成为商城额外收入。
在 Bitcoin、EVM 原生币或支持的 ERC-20 代币上,多付不会阻止按报价向商家付款。商家份额不变,多付的部分会单独保留,用于对账。
打款停滞或请求超时
检查已保存的交易哈希、来源余额、gas 和审核原因。继续或核对现有打款,不要因为响应丢失就再建一笔转账。恢复备份后保持打款暂停,直到链上结果与账本一致。
只有两个独立提供商证明失败的 EVM 转账已在链上确认回滚后,恢复操作才能发起替代转账。结果不明时资金仍保持预留,失败尝试产生的手续费仍计入原预算。
客户需要退款
核实由客户控制的退款地址。目前支持同资产全额退款。若全部卖家已收款,需单独为主钱包补足资金;Wholly Crypto 无法从卖家手中扣回。只付出部分卖家款项的批次需要人工对账,不能假定有部分退款按钮。
8. 上线前检查
- 用小额 BTC 和/或 EVM 付款走完流程,直到卖家打款确认。核对链、代币合约、目标地址、佣金及独立费用。
- 测试重复回调、API 响应超时、少付和暂扣打款,确认都不会导致重复履约或重复转账。
- 将私密备份放在服务器之外,监控打款失败,并定期核对应付卖家金额与链上资金。
先从少量卖家和人工审核打款开始。只自动化你已测试过的资产、预算和目标地址。