友商接入(服务端签名 · 三请求头)
APPSECRET 永远不能进浏览器。 只放在友商服务器(或受控 BFF)上用于签名。
1
获取 APPID / APPKEY / APPSECRET
登录后打开 开发者控制台 创建应用。 平台会为应用生成 APPKEY(公钥) 与 APPSECRET(私钥种子) 一对: 你在服务端用 APPSECRET 对请求做签名,平台用已登记的 APPKEY 验签;平台只保存 APPKEY,不保存 APPSECRET,APPSECRET 须由你方服务器自行保管。APPSECRET 仅在创建成功时展示一次,请务必复制并安全保存。
2
友商服务端计算 Ed25519 签名
待签消息为 UTF-8 字符串 APPID + "\n" + 商户用户 ID(中间一个换行符);用 sk_ 种子推导私钥签名;X-Signature 为 64 字节签名的 base64url(无填充)。详见 签名与多语言示例。
3
调用业务 API
每个请求的 HTTP 头中须携带:X-App-Id(与控制台 APPID 一致)、X-Uid(商户用户 ID)、X-Signature(上一步)。
然后调用 /trades/*、/positions/* 等。管理站用户可使用 Authorization: Bearer(HS256)。
密钥遗失:控制台「重新生成密钥」—— APPID 不变,旧私钥签出的请求全部失效。
流程图(总览)
时序图
服务端验签(Polymarket 内部)
签名规范与多语言示例
与后端 internal/partnerauth.SignedMessage 一致:待签内容为 APPID + "\n" + 用户 ID 的 UTF-8 字节。 从 sk_ 解析 32 字节 seed → ed25519.NewKeyFromSeed(Go)或等价 API。
请求头
| 头 | 要求 |
|---|---|
X-App-Id | 等于控制台的 APPID |
X-Uid | 商户用户 ID(与签名中 UID 一致),非空,≤128 字符 |
X-Signature | 对上述消息的 Ed25519 签名,base64url |
Go
package main
import (
"crypto/ed25519"
"encoding/base64"
"strings"
)
func main() {
appID := "<你的 APPID>"
uid := "user_123"
appSecret := "sk_..." // sk_ + base64url(32-byte seed)
seed, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(appSecret, "sk_"))
priv := ed25519.NewKeyFromSeed(seed)
msg := []byte(appID + "\n" + uid)
sig := ed25519.Sign(priv, msg)
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
_ = sigB64
}Node.js(@noble/ed25519)
import { sign } from '@noble/ed25519';
const appId = '<你的 APPID>';
const uid = 'user_123';
const seed = Uint8Array.from(Buffer.from('sk_....'.slice(3), 'base64url'));
const msg = new TextEncoder().encode(`${appId}\n${uid}`);
const sig = await sign(msg, seed);
const sigB64 = Buffer.from(sig).toString('base64url');Python 3
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
app_id = "<你的 APPID>"
uid = "user_123"
app_secret = "sk_..." # sk_ + base64url seed
seed = base64.urlsafe_b64decode(app_secret.removeprefix("sk_") + "==")[:32]
priv = Ed25519PrivateKey.from_private_bytes(seed)
msg = f"{app_id}\n{uid}".encode("utf-8")
sig_b64 = base64.urlsafe_b64encode(priv.sign(msg)).decode().rstrip("=")
print(sig_b64)Java(JDK Ed25519)
// 伪代码:Base64URL 解码 sk_ 去掉前缀得 32B seed → Ed25519PrivateKey
// 对 UTF-8(APPID + "\n" + 用户ID) 签名 → Base64URL 无填充 → X-Signature💡 将下文 测试向量 复制到你方环境,签出同字符串即表示实现正确。
测试向量(可复现)
由仓库 go run ./scripts/gen_partner_signature_vectors 生成。
| APPID | app_doc_vector_01 |
| APPKEY | ak_sGaWp6jdFNlgrLwAmWDCkT-oy0P816_MHQQtREUXmlE |
| APPSECRET | sk_ASNFZ4mrze_-3LqYdlQyEBEiM0RVZneImaq7zN3u_wA |
| X-Uid | demo_user_001 |
| 消息(UTF-8) | app_doc_vector_01 + 换行 + demo_user_001 |
| X-Signature | PLTcR4vRdyxdG5v94aobkU9ClRTfNSSAsjt25DS2y-2wcHXtIJyjAHBwgHwInAzXZBfKWiJgJ1t9QC6dI15kAQ |
仅在平台登记了相同 APPID→APPKEY 时验签通过;控制台新建应用会得到不同密钥。
📈 交易流程
① 获取市场列表
GET /markets?status=ACTIVE
// status 可选:PENDING | ACTIVE | PAUSED | SETTLED② 试算(强烈建议先 preview)
POST /trades/preview
X-App-Id: <APPID>
X-Uid: <商户用户ID>
X-Signature: <base64url Ed25519>
# 或使用管理站:Authorization: Bearer <HS256>
{
"marketId": "市场UUID",
"side": "YES", // YES 或 NO
"tickets": 10 // 购买票数
}
// 响应
{
"currentPrice": 0.5,
"netCost": 5.25, // 实际扣款(含5%手续费)
"priceImpact": 0.002, // 价格影响
"newYesPrice": 0.502,
"maxTickets": 490
}③ 正式下单
POST /trades/buy
X-App-Id: <APPID>
X-Uid: <商户用户ID>
X-Signature: <base64url Ed25519>
# 或 Authorization: Bearer <HS256>
{
"marketId": "市场UUID",
"side": "YES",
"tickets": 10
}
// 响应
{
"trade": {
"id": "交易UUID",
"netCost": "5.25",
"tickets": "10",
"side": "YES"
},
"newBalance": 994.75,
"yesPrice": 0.502,
"noPrice": 0.498
}💡 下单成功后服务端会自动通过 WebSocket 向该市场所有订阅者广播
price_update 和 trade_feed 事件。🔌 WebSocket 实时推送
连接端点
wss://polymarket.gzshujiu.com/api/ws最小接入示例(浏览器)
const ws = new WebSocket('wss://polymarket.gzshujiu.com/api/ws');
ws.addEventListener('open', () => {
// 订阅指定市场
ws.send(JSON.stringify({
type: 'subscribe_market',
marketId: '市场UUID'
}));
// 同时订阅 BTC 实时价格
ws.send(JSON.stringify({ type: 'subscribe_btc' }));
});
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case 'price_update':
// { yesPrice, noPrice, yesPool, noPool, maxYesTickets, maxNoTickets }
updatePriceDisplay(msg);
break;
case 'trade_feed':
// { side, tickets, price, grossCost, timestamp }
addTradeToFeed(msg);
break;
case 'market_settled':
// { marketId, result: 'YES'|'NO', totalPayout }
showSettlementResult(msg);
break;
case 'btc_price':
// { price, timestamp }
updateBtcBanner(msg);
break;
}
});
// 心跳保活(每25秒)
setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 25000);全部客户端消息类型
| type | 附加字段 | 说明 |
|---|---|---|
subscribe_market | marketId | 订阅市场行情,回 subscribed |
unsubscribe_market | marketId | 取消订阅 |
subscribe_btc | — | 订阅BTC实时价格 |
unsubscribe_btc | — | 取消BTC订阅 |
ping | — | 心跳,回 pong |
📊 市场状态与生命周期
状态说明
| status | statusCode | 中文 | isTradable | 说明 |
|---|---|---|---|---|
PENDING | 0 | 待开盘 | false | startTime 未到 |
ACTIVE | 1 | 交易中 | true | 可正常下单 |
PAUSED | 2 | 封盘结算中 | false | 停止下单,等待派奖 |
SETTLED | 3 | 已结算 | false | 已派奖完毕 |
💡 推荐使用
GET /markets/{id}/status 获取状态,它会根据当前时间自动纠正(比如到了 settlementTime 但还未手动结算,会返回 PAUSED 而不是 ACTIVE)。直接用 isTradable 字段控制下单按钮最简单。⚠️ 错误码速查
| HTTP | message | 含义 / 处理建议 |
|---|---|---|
| 400 | Insufficient balance | 余额不足,提示用户充值 |
| 400 | Market is not active | 市场不可交易,刷新市场状态 |
| 400 | Exceeds maximum allowed tickets | 超过最大可购票数,降低数量 |
| 400 | YES side is frozen... | 该侧价格触 99% 上限,引导用户买对侧 |
| 400 | X-App-Id, X-Uid…(缺一) | 三伙伴请求头须同时发送 |
| 401 | Missing authentication… | 未带三头且未带 Bearer |
| 401 | invalid signature / unknown APPID | 签名或 APPID 错误 |
| 401 | Invalid or expired token | HS256 管理员 token 无效或过期 |
| 403 | Insufficient permissions | 非管理员调用了管理员接口 |
| 404 | Market not found | 市场 ID 不存在 |