Partner Regrant 接口
面向 Partner:通过接口把你持有的额度转赠给自己的用户 —— 你会从我们这里拿到什么、约束条件与接口契约。
开始之前:你会从我们这里拿到什么
先把你要用来签名的钱包地址发给我们。落座完成、额度发到这个钱包后,我们会发给你一份partner 接入信息包,包含下面五项 —— 有了它就能发出第一个请求。然后照着 完整示例做。
用 Google、X、Discord 或 TikTok 注册的?你的额度在一个 Privy 钱包里。 点本页右上角的 Log in 登录后,可以直接在本页签名, 私钥不会离开 Privy。只有要让你自己的后端签名时才需要私钥 —— 这时打开钱包菜单,选 Export private key。私钥显示在 Privy 自己的窗口里,我们看不到,请像保管任何私钥一样保管它。
| 签名钱包 | 你提供给我们的地址,已落座为 partner。每个请求都用它签名,额度也在它名下。 |
| API Base URL | Pay API 的 host。不是本站,也没有公开,所以随信息包一起给你。 |
| source_lot_id | 你要从中转赠的 Lot:32 位 hex,不是 campaign id。你的钱包没有接口能列出自己的 Lot。 |
| Lot 的额度、到期日和服务范围 | 最多能转出多少、每行最晚能设到哪天到期、额度是否锁定在某个服务。 |
| external_ref 前缀 | 你的钱包地址(小写)加冒号。每一行的引用号都必须以它开头。 |
1. POST /v1/regrants
POST https://<pay-api-host>/v1/regrants
# host 不是本站 —— 在你的 partner 接入信息包里
X-Pay-Timestamp: 1789000000 # unix 秒,与我们的时钟相差需在 5 分钟内
X-Pay-Signature: 0x… # 对下面那一行字符串做 personal_sign
{"source_lot_id": "01a0…",
"items": [
{"external_ref": "0xacme…:sep-airdrop:u-1",
"account_ref": {"kind": "evm_address", "id": "0xAAA…"},
"amount_micros": 10000000, # $10.00
"expires_at": "2026-10-15T00:00:00Z"},
{"external_ref": "0xacme…:sep-airdrop:u-2",
"account_ref": {"kind": "evm_address", "id": "0xBBB…"},
"amount_micros": 10000000} # 不填到期日 → 继承自己 Lot 的
]}- 单次最多 500 行。金额是 micro-USD 整数:$10.00 写作 10000000。
- 收款方只能是钱包地址。没用过 Pay 的钱包会自动建账户;不接受邮箱和登录 ID。
- 按请求顺序逐行付款。某一行要求的金额超过 Lot 剩余余额时,该行报 lot_unusable,所以把重要的付款排在前面;请逐行读取结果,不要假设第一个被拒的行之后全部失败。
- expires_at 可选,上限是你 Lot 的到期日。填得更晚不会报错,会直接给 Lot 的到期日,所以请以响应里的值为准。
- 每行可以带一个可选的 service 字段。不填则继承你 Lot 的;如果你的额度锁定在某个服务上,填了别的服务会被拒(scope_widened)。
- 付款无法通过接口撤回。发送前请核对钱包名单;发错地址需要撤销时,请联系对接的 BD。
签名
pay-v2|POST|/v1/regrants|1789000000|9b74c9897bac770ffc029102a200c5de…
pay-v2 字面常量 —— 不是你的名字,也不是版本号
POST HTTP 方法,大写
/v1/regrants 路径;将来如果有 query string 也要带上
1789000000 和 X-Pay-Timestamp 里发的是同一个 unix 秒
9b74c989… 请求体的 sha256,小写 hex,64 个字符 ——
签的是哈希,不是请求体本身
用 "|" 连成一行。没有换行,没有空格,末尾没有分隔符。
对这一行做 EIP-191 personal_sign,发 0x + 65 字节 r||s||v。
v 是 0/1 还是 27/28 都行,两种都能恢复出地址,你的库给什么就发什么。
哈希你真正发出去的那串字节 —— 重新序列化一遍 JSON 再哈希,
会得到不同的摘要,也就会恢复出不同的签名者。签错了字符串,照样能恢复出一个地址,只是不是你的,结果是 403。 确信钱包已开通却收到 403 时,先逐字符比对签名串。不要发 X-Actor-Subject,这个接口会以 400 拒绝。
2. 完整示例
按信息包设置 PAY_API_URL、PARTNER_PRIVATE_KEY 和 SOURCE_LOT_ID,换成真实收款方,在你的服务端运行即可。每一轮都要把 sep-airdrop 换成新的批次 id,否则第二轮每行都报 duplicate,一个人都不会收到。
Node.js
// npm i viem (Node 18+, ESM)
import { createHash } from "node:crypto";
import { privateKeyToAccount } from "viem/accounts";
const PAY_API = process.env.PAY_API_URL; // from your partner kit, no trailing slash
const account = privateKeyToAccount(process.env.PARTNER_PRIVATE_KEY); // the seated wallet
const prefix = account.address.toLowerCase();
// Serialise ONCE. These exact bytes are hashed, signed and sent.
const body = JSON.stringify({
source_lot_id: process.env.SOURCE_LOT_ID,
items: [
{ external_ref: `${prefix}:sep-airdrop:u-1`,
account_ref: { kind: "evm_address", id: "0xAAA…" },
amount_micros: 10_000_000 }, // $10.00
],
});
const path = "/v1/regrants";
const ts = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash("sha256").update(body).digest("hex");
const signature = await account.signMessage({ // EIP-191 personal_sign
message: `pay-v2|POST|${path}|${ts}|${bodyHash}`,
});
const res = await fetch(PAY_API + path, {
method: "POST",
headers: { "Content-Type": "application/json",
"X-Pay-Timestamp": ts, "X-Pay-Signature": signature },
body,
});
const out = await res.json();
if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(out)}`); // whole batch refused
for (const it of out.items) console.log(it.external_ref, it.outcome, it.reason ?? "");Python
# pip install eth-account requests
import hashlib, json, os, time
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
PAY_API = os.environ["PAY_API_URL"] # from your partner kit, no trailing slash
acct = Account.from_key(os.environ["PARTNER_PRIVATE_KEY"]) # the seated wallet
prefix = acct.address.lower()
# Serialise ONCE. These exact bytes are hashed, signed and sent.
body = json.dumps({
"source_lot_id": os.environ["SOURCE_LOT_ID"],
"items": [
{"external_ref": f"{prefix}:sep-airdrop:u-1",
"account_ref": {"kind": "evm_address", "id": "0xAAA…"},
"amount_micros": 10_000_000}, # $10.00
],
}, separators=(",", ":")).encode()
path = "/v1/regrants"
ts = str(int(time.time()))
body_hash = hashlib.sha256(body).hexdigest()
signed = acct.sign_message(encode_defunct(text=f"pay-v2|POST|{path}|{ts}|{body_hash}"))
signature = "0x" + bytes(signed.signature).hex() # EIP-191 personal_sign
res = requests.post(PAY_API + path, data=body, timeout=30, headers={
"Content-Type": "application/json",
"X-Pay-Timestamp": ts, "X-Pay-Signature": signature})
res.raise_for_status() # whole batch refused
for it in res.json()["items"]:
print(it["external_ref"], it["outcome"], it.get("reason", ""))或者在这里签名:用你登录的钱包
用你在本站登录的钱包为一次请求签名,然后自己发出去。本页面不会发出任何请求,也不需要私钥 —— 钱包签完只返回签名。
3. external_ref 与重试
必须以你的钱包地址(小写)加冒号开头,否则该行被拒(external_ref_prefix)。
重试是安全的。引用号会被永久记住:再发一次不会重复付款,而是报 duplicate。超时后原样重发同一批即可。
0xacme…:sep-airdrop:u-1 0xacme…:sep-airdrop:u-2 └─前缀─┘ └─批次id─┘ └用户id┘ 总长度控制在 120 字符以内
| 错误用法 | 后果 |
|---|---|
| 重试时改了引用号的任何一部分 | 会被当作一笔新付款 —— 该用户被付两次 |
| 新一轮发放复用上一轮的批次 id | 每行都报告为已完成,一个人都没收到钱 |
| 同一批里重复同一个引用号 | 第二行报告为 duplicate,看起来像凭空少了一行 |
4. 返回与错误
200 OK
{"granted_count": 2, "duplicate_count": 0, "rejected_count": 0,
"granted_micros": 20000000,
"items": [
{"external_ref": "0xacme…:sep-airdrop:u-1", "outcome": "granted",
"account_id": "01a0…", "account_created": true, "lot_id": "01a0…",
"depth": 1, "amount_micros": 10000000,
"expires_at": "2026-10-15T00:00:00Z"},
{"external_ref": "0xacme…:sep-airdrop:u-2", "outcome": "granted",
"account_id": "01a0…", "account_created": true, "lot_id": "01a0…",
"depth": 1, "amount_micros": 10000000,
"expires_at": "2026-12-31T00:00:00Z"} # 未指定,继承了 Lot 的到期日
]}结果逐行返回,失败的行不会回滚已成功的行。每行是以下三种结果之一:
| outcome | 含义 | 该做什么 |
|---|---|---|
| granted | 已付 | 无 |
| duplicate | 这个引用号之前付过了 | 无 —— 这说明重试机制在正常工作 |
| rejected | 见下方原因 | 视原因而定 |
拒绝原因
| code | 原因与处理 |
|---|---|
| lot_unusable | 额度用完了。最常见的一个,它精确对应「钱不够之后的那些行」。找 BD 追加,然后原样重发同一批:已付的报 duplicate,只有差额那些会真正过账。 |
| unknown_account | 收款方无法创建。钱包会自动创建,邮箱和登录 ID 不行 —— 没有人能证明它们属于谁。改用钱包地址。 |
| scope_widened | 你的额度锁定在某个服务,这一行却要求了另一个。不填 service 即可继承你的。 |
| external_ref_conflict | 这个引用号之前用过,但金额或收款方不同。说明拿同一个(批次, 用户)做了不同的付款,给新的那笔换一个批次 id。 |
| external_ref_prefix | 引用号没有以自己的钱包地址开头。加上前缀重发。 |
| invalid_amount | 金额为零或负数。 |
整批失败(非逐行)
| 状态 | 原因 |
|---|---|
| 400 | 发了 X-Actor-Subject |
| 401 | 签名本身不合法,根本恢复不出地址 |
| 403 actor_not_authorized | 恢复出来的地址未被落座为 partner,或已落座但还没有账户(没被发放过额度)。签名串写错也会落到这里:它恢复出的是另一个地址,而那个地址没落座。 |
| 404 unknown_lot | source_lot_id 不是你持有的 Lot。在这里填 campaign id 得到的就是它 —— 不是 403,也不是逐行的 lot_unusable。 |