PuppyIP / Developer Docs
客户 API 开发文档
PuppyIP 客户 API 使用 HMAC-SHA256 签名。沙盒和正式环境使用相同路径、请求头、签名规则和响应包络;联调完成后,只需要替换 Base URL、App ID 和 API Secret。
接入步骤
- 1. 正式环境“我的 API”页面提供沙盒联调入口。进入测试环境后,登录客户中心,进入“我的 API”,创建
sbx_app_开头的 App ID,并立即保存 API Secret。 - 2. 把
PUPPYIP_API_BASE_URL、PUPPYIP_API_APP_ID和PUPPYIP_API_SECRET放进服务端环境变量,不要写进前端页面。 - 3. 先请求
GET /api/v1/account验证签名,再请求GET /api/v1/offers获取可用产品。 - 4. 创建订单或续费时,为一次业务操作生成一个
Idempotency-Key。同一次操作的超时或网络重试必须复用原 key 和完全相同的原始 body;只有发起新的业务操作时才更换 key。 - 5. 下单成功表示订单已受理。保存返回的
order_no,按响应中的poll_after_seconds查询订单详情,直到订单完成或退款。 - 6. 服务端请求建议设置稳定的
User-Agent,例如PuppyIP-Integration/1.0,避免默认脚本标识被安全策略误判。 - 7. 沙盒调通后,回到正式环境创建
live_app_开头的 App ID。上线只替换正式环境变量。复制代码示例后,只需要替换环境变量,签名函数、请求头和接口路径不需要重写。
环境切换
建议把连接信息放在环境变量中。测试使用 PUPPYIP_API_BASE_URL=https://sandbox.puppyip.com 和 sbx_app_ 开头的 App ID;上线改为 PUPPYIP_API_BASE_URL=https://puppyip.com 和 live_app_ 开头的 App ID。测试和上线不需要改请求头、签名函数或接口路径。
PUPPYIP_API_BASE_URL=https://sandbox.puppyip.com
PUPPYIP_API_APP_ID=sbx_app_EXAMPLE_SANDBOX_APP_ID
PUPPYIP_API_SECRET=replace-with-your-secret
当前可用接口
| Endpoint | 用途 | 说明 |
|---|---|---|
| GET /api/v1/account | 账号信息 | 读取当前 API Key 所属账号。 |
| GET /api/v1/wallet | 钱包余额 | 生产环境返回真实钱包余额,沙盒返回测试余额。 |
| GET /api/v1/offers | 可购产品 | 支持 country_code(或 country)、region(或 city)、line、ip_type、quantity、duration_days 查询参数,并使用 page_size 和 page_token 获取完整线路目录。 |
| POST /api/v1/orders | 创建订单 | 必须带 Idempotency-Key,可在一笔订单中购买多个地区,只从钱包余额扣款。 |
| GET /api/v1/orders | 订单列表 | 返回安全摘要,使用 page_size 和 page_token 稳定翻页;不在列表中返回 IP 凭据。 |
| GET /api/v1/orders/{order_no} | 订单详情 | 只能读取当前账号自己的订单。 |
| GET /api/v1/proxies | IP 列表 | 返回当前账号已交付的 IP;新接入请使用 page_size 和 page_token 翻页。 |
| GET /api/v1/proxies/{proxy_id} | IP 详情 | 只能读取当前账号自己的 IP。 |
| POST /api/v1/proxies/renew | 续费 | 必须带 Idempotency-Key,只从钱包余额扣款。 |
响应包络
所有接口都返回统一 JSON 包络。成功时 success=true,业务数据在 data;失败时 code 是稳定错误码,trace_id 可用于联系支持排查。
{
"success": true,
"code": "ok",
"message": "OK",
"data": {},
"timestamp": "2026-06-28T12:00:00+00:00",
"trace_id": "trc_EXAMPLE_TRACE_ID"
}
权限与字段约定
账号、钱包、产品读取分别需要 account:read、wallet:read、offers:read;订单列表和详情需要 orders:read,下单需要 orders:write;IP 列表和详情需要 proxies:read,续费需要 proxies:write。Key 只允许访问所属账号的数据;换密和换 IP 不属于客户 API v1。
account 返回 account_id、email、email_verified、status、created_at。wallet 返回 environment、currency、balance_cents、balance、frozen_cents、cash_value;沙盒 cash_value=false。金额为整数分或对应的十进制字符串,不要使用二进制浮点数累加账单。
offers 每项包含 offer_code、name、country_code、country_name、region、line_name、ip_type、protocol、unit_price_cents、unit_price、base_duration_days、duration_days、max_quantity、cidrs。duration_days 是可购时长数组;报价受请求 quantity、duration_days 和账号价格影响,不预留库存。
订单详情包含 order_no、status、payment_status、payment_method、currency、subtotal_cents、total_cents、total、created_at、items、delivery、poll_after_seconds、refund、proxies。列表只返回安全摘要,不返回代理凭据。refund 为 null 或退款状态对象,含 status、destination、amount_cents、currency、refunded_at;不能仅凭订单文字状态推断退款金额。
IP 项包含 proxy_id、status、host、port、protocol、username、password、connection、socks5_uri、ip_type、country_code、region、line_name、starts_at、expires_at。凭据只供账号服务端使用,禁止写入公开日志。时间使用带时区的 ISO 8601 字符串,尚无时间时可为 null;公共 ID 原样使用,不是内部资源 ID。
POST 请求使用 application/json。续费 proxy_ids 必须为非空公共 ID 数组,duration_days 必须为 30 的正整数倍且不超过 1095。分页 token 为不透明字符串;接口响应有可选字段,接入端应容忍新增字段,并对未知业务状态停止自动重试、保留 trace_id 联系支持。
请求头
| Header | 说明 | 示例 |
|---|---|---|
| X-API-AppId | 账号页生成的 App ID。 | live_app_EXAMPLE_LIVE_APP_ID |
| X-API-Timestamp | 绝对时间戳,必须带时区,允许 5 分钟内的时钟偏差。 | 2026-06-28T12:00:00+00:00 |
| X-API-Nonce | 每次请求唯一随机值,重复使用会被拒绝。 | nonce-empty-body |
| X-API-Signature | 用 API Secret 对 canonical string 计算出的 HMAC-SHA256 小写十六进制值。 | e3c27a2055ad38f16861790294802b9fac81e02e8d72fbeaa87299598785832d |
| Idempotency-Key | 创建订单和续费必填。同一账号下,相同 key 加相同原始 body 会重放第一次响应(轮换 App ID 也不会重复执行);相同 key 加不同 body 会返回冲突。 | order-demo-001 |
| User-Agent | 建议设置稳定的服务端客户端标识,避免默认脚本标识被安全策略误判。 | PuppyIP-Integration/1.0 |
| X-PuppyIP-Sandbox-Scenario | 仅沙盒下单可用,用于模拟 success、delayed、partial、rejected;不传时保持即时成功响应,正式环境会拒绝该请求头。 | partial |
Canonical String
签名前先把请求拆成 7 行,用换行符 \n 连接:
METHOD
host
path
canonical_query
timestamp
nonce
sha256(body)
METHOD 使用大写,例如 GET 或 POST。
host 来自 Base URL 的主机名,使用小写,不包含协议。
path 必须包含开头斜杠,例如 /api/v1/account。
canonical_query 对 query 的 key 和 value 分别 URL 解码后再 rawurlencode,按 key、value 升序排序,重复 key 不合并。没有 query 时这一行留空。
body 使用原始请求体计算 SHA-256。GET 请求没有 body 时,使用空字符串的 SHA-256。
GET /api/v1/account 固定示例
GET
puppyip.com
/api/v1/account
2026-06-28T12:00:00+00:00
nonce-empty-body
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
上面这组固定示例使用文档测试 Secret puppyip-docs-test-secret 计算出的签名是
e3c27a2055ad38f16861790294802b9fac81e02e8d72fbeaa87299598785832d。
代码示例
以下示例使用环境变量。请不要把 API Secret 写入前端页面、公开仓库或日志。
curl
curl 只负责发送请求。请先用下方 PHP 或 Node.js 签名函数,为这一次请求生成当前的 TIMESTAMP、NONCE 和 SIGNATURE;不要复制上面的固定算法校验值发送请求,也不要把 Secret 写进 shell 历史。
curl "$PUPPYIP_API_BASE_URL/api/v1/account" \
-H "X-API-AppId: $PUPPYIP_API_APP_ID" \
-H "X-API-Timestamp: $TIMESTAMP" \
-H "X-API-Nonce: $NONCE" \
-H "X-API-Signature: $SIGNATURE" \
-H "Accept: application/json" \
-H "User-Agent: PuppyIP-Integration/1.0"
PHP
$baseUrl = rtrim(getenv('PUPPYIP_API_BASE_URL') ?: 'https://sandbox.puppyip.com', '/');
$appId = getenv('PUPPYIP_API_APP_ID') ?: '';
$secret = getenv('PUPPYIP_API_SECRET') ?: '';
$method = 'GET';
$target = $baseUrl.'/api/v1/account';
$parts = parse_url($target);
$path = $parts['path'] ?? '/';
$rawQuery = $parts['query'] ?? '';
$body = '';
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
$nonce = 'nce_'.bin2hex(random_bytes(16));
$host = strtolower($parts['host'] ?? '');
function canonicalQuery(string $rawQuery): string
{
if ($rawQuery === '') {
return '';
}
$pairs = array_map(function (string $pair): array {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
return [rawurlencode(rawurldecode($key)), rawurlencode(rawurldecode($value))];
}, explode('&', $rawQuery));
usort($pairs, fn (array $left, array $right): int => $left[0] <=> $right[0] ?: $left[1] <=> $right[1]);
return implode('&', array_map(fn (array $pair): string => $pair[0].'='.$pair[1], $pairs));
}
$canonical = implode("\n", [
$method,
$host,
$path,
canonicalQuery($rawQuery),
$timestamp,
$nonce,
hash('sha256', $body),
]);
$signature = hash_hmac('sha256', $canonical, $secret);
$headers = [
'X-API-AppId: '.$appId,
'X-API-Timestamp: '.$timestamp,
'X-API-Nonce: '.$nonce,
'X-API-Signature: '.$signature,
'Accept: application/json',
'User-Agent: PuppyIP-Integration/1.0',
];
$handle = curl_init($target);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
]);
$response = curl_exec($handle);
if ($response === false) {
throw new RuntimeException(curl_error($handle));
}
echo $response;
Node.js
import crypto from 'node:crypto';
const baseUrl = process.env.PUPPYIP_API_BASE_URL ?? 'https://sandbox.puppyip.com';
const appId = process.env.PUPPYIP_API_APP_ID ?? '';
const secret = process.env.PUPPYIP_API_SECRET ?? '';
const method = 'GET';
const url = new URL('/api/v1/account', baseUrl);
const body = '';
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const nonce = `nce_${crypto.randomBytes(16).toString('hex')}`;
const rfc3986 = (value) => encodeURIComponent(value).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
const compare = (left, right) => left < right ? -1 : (left > right ? 1 : 0);
const canonicalQuery = (rawQuery) => {
if (rawQuery === '') return '';
return rawQuery
.split('&')
.map((pair) => {
const separator = pair.indexOf('=');
const key = separator === -1 ? pair : pair.slice(0, separator);
const value = separator === -1 ? '' : pair.slice(separator + 1);
return [rfc3986(decodeURIComponent(key)), rfc3986(decodeURIComponent(value))];
})
.sort((left, right) => compare(left[0], right[0]) || compare(left[1], right[1]))
.map(([key, value]) => `${key}=${value}`)
.join('&');
};
const rawQuery = url.search.startsWith('?') ? url.search.slice(1) : url.search;
const canonical = [
method,
url.hostname.toLowerCase(),
url.pathname,
canonicalQuery(rawQuery),
timestamp,
nonce,
crypto.createHash('sha256').update(body).digest('hex'),
].join('\n');
const signature = crypto
.createHmac('sha256', secret)
.update(canonical)
.digest('hex');
const headers = {
'X-API-AppId': appId,
'X-API-Timestamp': timestamp,
'X-API-Nonce': nonce,
'X-API-Signature': signature,
Accept: 'application/json',
'User-Agent': 'PuppyIP-Integration/1.0',
};
const response = await fetch(url, { method, headers });
console.log(await response.json());
业务请求示例
下单和续费都必须使用钱包余额,并带上 Idempotency-Key。签名时,body hash 必须使用发送出去的原始 JSON 字符串。
创建多地区订单
POST /api/v1/orders
Idempotency-Key: order-demo-001
{
"duration_days": 30,
"items": [
{"offer_code": "sandbox_us_static", "quantity": 2},
{"offer_code": "sandbox_jp_static", "quantity": 1}
]
}
items 最多 50 行,整笔订单总数量不能超过 2000。同一个产品不能重复提交相同 CIDR,也不能同时混用随机分配和指定 CIDR。旧版单地区格式 offer_code + quantity + duration_days 继续兼容,但不能与 items 同时出现。duration_days 必须是 30 的正整数倍,且符合所选产品支持的时长。
稳定分页
GET /api/v1/offers?page_size=200
{
"offers": [],
"page_size": 200,
"has_more": true,
"next_page_token": "opaque-token-from-response"
}
请求下一页时,把返回的 next_page_token 原样放入 page_token,并保持相同的 page_size 和筛选条件,例如 GET /api/v1/offers?page_size=200&page_token=...。线路、订单和 IP 列表均使用相同响应字段;线路每页最多 200 条,订单和 IP 的每页上限以接口校验为准。token 绑定当前账号、环境、资源类型和线路筛选条件,篡改、过期、跨账号或更换筛选条件使用都会被拒绝。
续费 IP
POST /api/v1/proxies/renew
Idempotency-Key: renew-demo-001
{
"proxy_ids": ["SANDBOX_PROXY_20260629080000_001"],
"duration_days": 30
}
{
"status": "completed",
"total_cents": 12000,
"charged_cents": 12000,
"refunded_cents": 0,
"successful_proxy_ids": ["SANDBOX_PROXY_20260629080000_001"],
"failed_proxy_ids": [],
"unresolved_proxy_ids": [],
"line_items": [{
"proxy_id": "SANDBOX_PROXY_20260629080000_001",
"host": "198.51.100.10",
"status": "succeeded",
"duration_days": 30,
"amount_cents": 12000,
"amount": "120.00",
"charged_cents": 12000,
"refunded_cents": 0
}]
}
原有续费响应字段保持不变,上例为 data 内的业务数据,IP 为示例值。status 为 completed 表示全部成功,partial 表示部分成功、明确失败的项目已退回钱包,manual_review 表示仍有结果待核对,不应重复提交。HTTP 200 或外层 success=true 不代表每条 IP 都续费成功。三组 proxy_ids 和 line_items 必须逐项核对。
line_items 每条包含 proxy_id、host、status、duration_days、amount_cents、amount、charged_cents、refunded_cents。逐项状态为 succeeded、failed 或 unresolved;proxies 中可读取各 IP 当前 expires_at。金额整数单位为分,amount/total 是主货币单位的十进制字符串。charged_cents 是钱包扣款减退款,不等于已经确认成功的续费金额:unresolved 项仍待核对,不自动退款。明确失败才退款,失败原因不明时不会编造原因或把未知当作失败。
全部明确失败仍返回 HTTP 422、success=false、code=operation_failed;此时 data.status=failed,并保留 failed_proxy_ids、line_items、charged_cents=0、refunded_cents 和 wallet。参数校验失败或扣款前余额不足不属于这个已退款结果,不保证包含续费明细。重放返回首次操作的结果快照;需要最新余额或到期时间时,单独读取 wallet 或 proxies。
GET /api/v1/offers 获取正式 offer_code,并用 GET /api/v1/proxies 获取正式 proxy_id。不要把沙盒返回的 ID 写进正式服务。沙盒续费目前仅模拟全部成功,字段与正式环境对齐,但不能用沙盒成功结果代替部分失败或待核对场景验证;接入方应按本文示例对这些状态补充本地测试。
订单状态与沙盒场景
创建订单返回 201 后,请使用 PuppyIP 返回的 order_no 查询 GET /api/v1/orders/{order_no}。paid 或 procuring 表示处理中;当 poll_after_seconds 为整数时,等待返回的秒数后再查询,为 null 或订单进入终态时停止轮询。收到 429 时优先遵循 Retry-After。只有 delivery.available=true 时,详情中的 proxies 才可交付客户。
{
"order_no": "SANDBOX_ORDER_EXAMPLE",
"status": "procuring",
"payment_status": "paid",
"delivery": {
"expected_count": 3,
"delivered_count": 1,
"complete": false,
"available": false
},
"poll_after_seconds": 1,
"refund": null,
"proxies": []
}
不传场景头:即时成功并返回全部测试 IP,兼容既有沙盒客户端。
success:正常异步完成并返回全部测试 IP。
delayed:延迟数秒后完成,用于验证轮询界面。
partial:先出现部分进度,再完成全部交付;未完成前不返回凭据。
rejected:不交付 IP,测试扣款自动退回测试钱包,refund 会明确返回去向和状态。
常见错误
- invalid_signature
- 签名缺失、App ID 错误、canonical string 拼接不一致,或使用了错误的 API Secret。
- stale_timestamp
- 时间戳没有时区、格式不是 ISO 8601,或客户端时间与服务器相差超过 5 分钟。
- replayed_nonce
- 同一个 API Key 下重复使用了相同 nonce。每次请求都应该生成新的随机 nonce。
- idempotency_key_required
- 创建订单或续费请求缺少 Idempotency-Key。
- idempotency_key_conflict
- 同一个 Idempotency-Key 已经用于不同请求体。
- idempotency_processing / idempotency_replay_failed
- 相同操作仍在处理中,或首次响应暂时无法安全重放。保持相同 key 和原始 body,按响应建议稍后重试。
- idempotency_unresolved
- HTTP 409。先前请求可能已经产生扣款或其他结果,需要核对;不能把异常或超时当作未执行,也不要换 key 自动重试。
- insufficient_scope
- API Key 没有所需权限,或请求来源 IP 不在允许范围内。
- api_key_revoked
- API Key 已停用。请换用仍有效且具有所需权限的 Key。
- validation_error
- 请求字段、数量、时长或组合不符合接口约束;按 data.errors 修正后再提交。
- offer_not_found / order_not_found / proxy_not_found
- 公共标识不存在、已不可用,或不属于当前账号和环境。请重新读取对应列表。
- insufficient_wallet_balance
- 钱包余额不足,创建订单或续费没有扣款成功。
- invalid_page_token
- 分页 token 已过期、被修改,或用于错误的账号、环境或资源。请从第一页重新获取。
- rate_limited
- 请求过快。读取 Retry-After 响应头后重试;不要用无间隔循环轮询。
- unsupported_operation
- 该操作不属于客户 API v1,例如修改代理账号密码或切换 IP。
- proxy_endpoint_unavailable / operation_failed
- 连接端点暂不可用或操作未完成。不要猜测内部原因;保存 trace_id,并按响应状态安全重试或联系支持。