西信数据

对外 API v1 · 开发文档

这份文档写给下游对接方:分销商、客户自己的脚本、第三方系统。
读完它你可以自己写程序,把商品、价格、服务、余额、开关机、开通和续费都接进去。

本版本一共 15 项能力:5 个只读接口、7 个服务操作、3 个任务接口。
每一项的请求参数、应答字段、专属错误码都在下面各自的卡片里。

全部能力都走同一个入口,用同一把 API 密钥,回同一种 JSON 信封。

接口 9 个 可调操作 6 项 鉴权 API 密钥 版本 v1
已开放 本站的对外接口开关是打开的,拿你自己的 API 密钥可以直接调。 如果仍然报错,看第五节的错误码和第十五节的排障。

接口入口

https://www.xxisp.com/api.php

每个接口都拼在它后面,例如商品列表就是 https://www.xxisp.com/api.php/v1/products。
如果这样调不通(服务器返回 404),改用不依赖服务器配置的写法: https://www.xxisp.com/api.php?path=/v1/products。

这一页怎么用

左栏是所有接口的索引,按方法打了徽章,点一下跳到右边对应的卡片。
每一块代码右上角都能一键复制;多语言的示例用上面的页签切换。
窄屏时左栏会收起来,点「展开接口索引」再选。

一、这个接口能做什么

下面是一张总览。每一条都能在左边索引里点进去看细节。

只读(查):

  • 商品列表和单个商品,带你这个账号每个周期的价
  • 你的云服务清单和单台服务(含 IP、规格、到期日)
  • 你这个账号的余额

写(真的会动机器、会动钱):

  • 同步信息:让本地记录跟上机房
  • 开机 / 关机 / 重启
  • 开通:按商品和周期下订单,并从账户余额扣款,成功后真正开机器
  • 续费:为某台服务开续费账单并扣账户余额,成功后顺延到期日

轮询:

  • 开机 / 关机 / 重启 / 同步是异步的,先回一个任务号,再去查任务结果
  • 还有两个接口用来列任务和查"我这一族能做哪些操作"

明确不提供的(很重要,别按它们写代码):删除机器、重装系统、重置密码、
强制关机、暂停 / 解除暂停、锁定 / 解锁。这些要么不可逆、要么会改掉客户手上的
凭据、要么是我们催费和封停的手段。它们不在操作表里,路由都匹配不上。

二、接口入口(三种写法)

本站的接口只有一个入口文件:站点根目录下的 api.php。
把下面的 <站点> 换成本站的 API 域名(以本站「会员中心 → 代理分销 → API 管理」
页显示的「API 接口地址」为准)。

三种写法同时有效,选一种用就行。它们的区别只在"依赖不依赖服务器配置"。

写法一:`/api.php/v1/...`(主用法)

#
GET https://<站点>/api.php/v1/products

api.php 是一个真实存在的文件,服务器会把它交给 PHP 执行;
后面的 /v1/products 由 PHP 自己的 PATH_INFO 接住。
这一种不需要站长改任何服务器配置,所以它是主用法。

写法二:`?path=/v1/...`(最保险,一定有效)

#
GET https://<站点>/api.php?path=/v1/products

为什么要有这一种:有的服务器在 PHP 的 location 里加了
try_files $uri =404,或者 if (!-f $request_filename) { return 404; }。
那种配置下,写法一里的 /api.php/v1/products 不是一个真实文件,
会被服务器直接判成 404 —— 你会以为是接口没做,其实是服务器配置那一层。

写法二不依赖任何服务器行为,只要 api.php 能执行就行。
所以排障的第一条命令就是:把写法一换成写法二。

写法三:裸路径 `/v1/...`(站长把 API 做成了独立域名时用)

#
GET https://<站点>/v1/products

这一种要站长把 API 域名做成一个独立站点,并在那里加一条 rewrite:

location ^~ /v1/ {
    rewrite ^/v1/(.*)$ /api.php?path=/v1/$1 last;
}

加了之后三种写法同时都能用,不会互相打架。没加的话裸路径是 404。

一个容易写错的细节

#

写法二里的 path 只取路径部分。即使你顺手把查询串写进去,例如
?path=/v1/products?cycle=monthly,后面的 cycle=monthly 也会被丢掉。

正确写法是把查询参数放在外层:

GET https://<站点>/api.php?path=/v1/products&cycle=monthly

支持的方法

#

只读接口收 GET 和 HEAD(HEAD 只回响应头、不回 JSON 正文)。
写接口收 POST。

用错方法不会 404,会拿到 405 加 method_not_allowed,并且文案里写明
这条路由收什么方法。这一点是有意做的:一个全局的"只许 GET"和一张有 POST
的路由表不可能同时成立,所以方法跟着每一条路由走。

三、身份:一把 API 密钥

密钥长什么样

#

idc_ 开头,后面 40 个十六进制字符,一共 44 个字符。

下面这个是一眼能看出是假的例子,不是真密钥:

idc_0123456789abcdef0123456789abcdef01234567

怎么拿到密钥

#
  1. 登录本站会员中心。
  2. 打开「代理分销 → API 管理」,地址是 /profile-api.php。
  3. 在那一页创建一把 API 密钥。

创建、重命名、轮换、吊销这四个动作,每一个都要当场用绑定手机号
收一条短信验证码(每个操作验一次,没有"验一次管一段时间")。

  1. 密钥明文只在创建或轮换的那一刻显示一次。本站数据库里只存它的

sha256 摘要,之后没有任何办法再看一遍。请当场抄进你的密码管理器
或者配置中心。忘了就"重新生成"(轮换)一次,旧密钥当场失效。

怎么把密钥发过来

#

按下面的优先级。代码就是按这个顺序取,取到第一个非空的为止:

顺序写法说明
1Authorization: Bearer <你的密钥>推荐用法,不会进访问日志
2X-Api-Key: <你的密钥>有些代理会吃掉 Authorization 头,那时用它
3?apikey=<你的密钥>不推荐,见下面的警告

?apikey= 这种写法请不要用。URL 会进服务器的访问日志、进浏览器历史、
进 Referer 头,等于把你的密钥到处撒。它存在只是为了照顾确实设不了
请求头的旧客户端。

一把密钥只能读写一个账号的数据

#

密钥属于创建它的那一个客户。接口只返回这个账号自己的数据。

地址里加任何参数都读不到别的账号的数据。就算你手写别人的编号,
拿到的也是 404,而不是 403 —— 本站不会告诉你"这个编号存在,只是不是你的"。
这条规则对只读接口和写接口一视同仁:拿自己的密钥去操作别人的机器,
也是 404,而且响应里没有一个字节属于那个账号。

密钥被吊销,或者账号本身不是正常状态之后,所有调用都会收到 401 加
invalid_api_key。"密钥不存在"、"密钥已吊销"、"账号被停用"这三种情况
返回完全一样的应答,这是故意的:否则别人可以拿它反推哪些编号存在。

请求示例代码

#

同一个请求,四种语言。四种都能直接跑(填上你的域名和密钥)。

API="https://<站点>/api.php"
KEY="你的API密钥"

curl -s -H "Authorization: Bearer $KEY" "$API/v1/services"
<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';

$ch = curl_init($api . '/v1/services');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT        => 20,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$j = json_decode((string) $body, true);
if (!is_array($j)) {
    throw new RuntimeException('不是 JSON,HTTP ' . $code);
}
if (empty($j['ok'])) {
    // 认 code,不要认 message
    throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
foreach ($j['data']['services'] as $s) {
    echo $s['name'], ' ', $s['status_label'], ' 到期 ', (string) $s['next_due_date'], "\n";
}
import json
import urllib.request

API = "https://<站点>/api.php"
KEY = "你的API密钥"

req = urllib.request.Request(API + "/v1/services",
                             headers={"Authorization": "Bearer " + KEY})
with urllib.request.urlopen(req, timeout=20) as resp:
    data = json.loads(resp.read().decode("utf-8"))

if not data.get("ok"):
    # 认 code,不要认 message
    raise RuntimeError("接口报错 %s" % data["error"]["code"])

for s in data["data"]["services"]:
    print("%s %s 到期 %s" % (s["name"], s["status_label"], s["next_due_date"]))
const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';

const res = await fetch(API + '/v1/services', {
  headers: { Authorization: 'Bearer ' + KEY },
});
const body = await res.json();          // 4xx / 5xx 也是 JSON 信封
if (!body.ok) {
  // 认 code,不要认 message
  throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
}
for (const s of body.data.services) {
  console.log(`${s.name} ${s.status_label} 到期 ${s.next_due_date}`);
}

四、应答格式(信封)

成功

#
{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "6701dfb649b5364c",
    "data": { }
}

失败

#
{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "8f606307b32b21b2",
    "error": {
        "code": "invalid_api_key",
        "message": "密钥无效。"
    }
}

五条固定规则

#
  1. ok 永远在最外层。先判它,再决定读 data 还是读 error。
  2. 成功的应答里没有 error 键;失败的应答里没有 data 键。

不是 "error": null,是根本没有这个键。所以可以一句
if (r.error) 判死,不用管 null 和缺键两种形状。

  1. error.code 是给机器看的:英文 snake_case,稳定,不会被翻译。

error.message 是给人看的:简体中文,文案会改。
请只把 message 打进日志,绝不要用程序去匹配它。

  1. request_id 每次请求都不同,是一个 16 位的十六进制字符串。

报障时把它提供给我们,我们就能对到那一条请求记录。

  1. 有一个例外要多看一眼:error.detail 只在真的有细节时才出现。

目前只有 409 那一族会带它(里面是"在途那一笔"的任务号)。

响应头

#
头值说明
Content-Typeapplication/json; charset=utf-8永远是 JSON
Cache-Controlno-store, no-cache, must-revalidate不允许任何中间层缓存
Pragmano-cache同上,兼容老客户端
X-Content-Type-Optionsnosniff防浏览器把 JSON 当页面渲染
X-Api-Versionv1当前版本
Retry-After60只在 429 时出现,单位是秒

接口不发 Set-Cookie,也不会 302 / 303 跳转。任何一次调用拿到的
都是 JSON 加状态码。

成功不止 200:还有一个 202

#
HTTP什么时候
200只读接口;以及当场就有结果的写操作(VNC / 续费 / 开通)
202异步写操作已受理(同步信息 / 开机 / 关机 / 重启)

202 的语义是"我收下了、还没做完"。拿到 202 就去轮询任务,
不要以为操作已经完成。202 的应答里 state 一定是 queued 或 running,
async 一定是 true。

拿不到 202 的环境(服务器不支持提前结束请求,而且起不了后台工作进程)
会退回 200 加 async: false,那次请求会等到机房返回为止。
所以请据 async 分辨,不要据状态码分辨。

五、错误码总表

这是下游密钥可能遇到的全部错误码。error.code 稳定,error.message 会变。

HTTPerror.code什么时候会出现
400bad_request请求体不合法(例如 op 不认识、product_id 没带、selection 结构不对)
400bad_cyclecycle 不是 monthly / quarterly / semiannually / annually 之一
400service_not_enabled这台服务不能下发:独立产品(没绑机房)、没绑机器、或者机房信息不完整
401unauthenticated一个凭据都没带
401invalid_api_key密钥无效。不存在 / 已吊销 / 格式不对 / 账号不是正常状态,全都归这一个 code
402insufficient_balance账户余额不足,这一笔花不起。error.detail 里带 balance / required / shortage / currency。这种情况下订单 / 账单 / 余额一个都不会产生
403operation_not_allowed这个操作不对客户密钥开放(目前只有 VNC)
404not_found商品、服务或任务不存在,或者存在但不属于这个账号
404endpoint_not_found路径不在本版本的接口表里。/v1 本身也是这个
405method_not_allowed这条路由不收这个 HTTP 方法
409operation_busy同一台服务的同一个操作正在处理中。响应里带 error.detail.op_id
429rate_limited触发限流,响应头带 Retry-After
500internal_error本站内部错误。请把 request_id 提供给我们
503api_disabled本站的接口整体关闭中。默认就是关闭的
503not_enabled本站的密钥功能还没启用(数据库迁移没执行)
503operation_store_unavailable写操作功能还没启用(任务表那个迁移没执行)。只读接口不受影响

同一个 code 对应的 HTTP 状态码是固定的。
反过来,一个 503 可能是三个不同的 code,所以判断时请认 error.code,
不要只认状态码。

还有一类"错误码"不在 `error` 里,在 `operation.outcome` 里

#

开通和续费是同步操作:它们先落任务行再干活。所以有两层失败:

  • 请求本身写错了 / 钱不够 ✗ 那是一条同步的 4xx,

走上面的 error(例如 402 insufficient_balance)✓
这种情况下什么都没发生,也不会留下任务行 ✓

  • 请求没问题,但业务没做成 ✗ HTTP 是 200,

ok 是 true,而 data.operation.state 是 failed ✓
这时稳定错误码在 data.operation.outcome 的最前面,
用全角竖线隔开,形如 provision_failed|订单已创建… ✓

后者可能出现的码:

码什么时候
payment_failed扣款那一步失败了(账单不属于这个账号、或者余额支付的其它失败)
insufficient_balance扣款那一步判余额不足(并发下前面查够、这里不够)
provision_failed钱已经扣了、账单已付清,但机器没开出来。result.needs_operator 是 true
renew_failed钱已经扣了、到期日已顺延,但机房那一段没完成。result.needs_operator 是 true
upstream_failed机房/业务类拒绝,而且没给出更具体的码(兜底)

所以判"到底成没成"请两步都看:先 ok / HTTP,再看
operation.state 和 operation.outcome 的前缀。

这里没有列出的 code 不会由下游密钥触发。如果你收到的 error.code
不在这张表里,请把 request_id 提供给我们。

两个真实例子:

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "8f606307b32b21b2",
    "error": {
        "code": "invalid_api_key",
        "message": "密钥无效。"
    }
}
{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "b0255c41667232df",
    "error": {
        "code": "api_disabled",
        "message": "本站的 API 接口当前未开放。请联系服务商。"
    }
}

六、钱:价格与折扣

这一节最容易搞错,请读完。

报出来的价就是下单会收的价

#

prices[].price 不是参考价。本站的接口和收银台调的是同一段代码,
同一个客户、同一个商品、同一个周期算出来的数逐分相同。
所以你可以放心拿它给自己的客户报价。

字段含义
list_price折前价
price实际要收的价
discount_percent这次实际施加的减免百分比,15.00 表示减 15%
saving减免了多少钱,等于 list_price 减 price

op=order 下订单时,账单金额和订单金额也是同一次计算的结果,
不是两处各算一遍碰巧相等。

金额是字符串,不是数字

#

所有金额都是两位小数的字符串:"85.00"、"123.45"、"0.00"。

不是数字类型,也不是浮点数。请不要转成浮点去累加,要算就按定点小数
或者换算成整数分来处理。金额为 null 只出现在 priced 为假时。

编号、月份、端口、库存、天数这些是数字类型。
布尔字段(auto_renew、auto_provision、required、is_default、
has_password、priced、dispatched、async)是真假值。

影子模式:报的是标准价

#

本站的客户等级折扣有一个开关,有三个状态:关、影子、开。

影子模式下,接口报的是标准价,也就是 price 等于 list_price、
discount_percent 为 0.00。

原因是影子模式的定义就是"算给你看,但一分钱都不动"。
收银台在影子模式下收的就是标准价。
如果接口报一个"开关打开后会是"的价格,那它报的就是一个客户不会被收的价,
你拿它去对账,只会对出一笔不存在的差额。

所以不要以为影子模式等于"我可以省一点"或者"我能在报价里预览折扣"。
影子模式下折扣不生效,报的价就是标准价,收的钱也是标准价。

怎么知道这个客户现在有没有折扣

#

看数字,不要看开关。接口不暴露本站折扣开关的状态。

判断方法只有一个:

  • discount_percent 大于 0.00,说明这个客户在这个商品上正在享受折扣,

price 就是折后价。

  • discount_percent 等于 0.00,说明现在没有任何折扣被施加,

price 等于 list_price。

你不需要、也没有办法从接口区分"开关关着"和"开关在影子模式",
因为这两种情况对你要做的事完全一样:要收的就是 price。
需要知道开关到底是哪一种、命中了哪条规则,那是本站运营侧的试算工具
要做的事,请联系本站管理员,不要写进你的对接程序。

开单和续费会**从账户余额扣款**

#

这一点必须说清楚,免得你按"调一下只是开一张单"去写程序:

  • op=order(开通):按商品与周期下订单,并从账户余额扣款。

余额不足会直接拒绝,不会下单、不会开通。
扣款成功后由本站既有的开通流程真正开机器。

  • op=renew(续费):为那台服务开续费账单并从账户余额扣款,

金额取本站为该服务设定的续费价。余额不足会直接拒绝,
不会开单、不会续期。扣款成功后由既有的续费流程顺延到期日并同步机房。

扣款用的就是客户在账单页点「用余额支付」的同一个函数,
所以"接口扣的钱"和"客户自己点一下扣的钱"是同一笔账、同一种记法。

余额不足那一笔什么都不会发生:不会下单、不会开单、不会扣款、
订单和账单一张都不会产生。这是刻意的(先查余额,再落单,最后才扣款)。

⚠️ 这个结果可能以两种形状出现,你的程序两种都要认:

  • 402 加 error.code = insufficient_balance,

error.detail 里带 balance / required / shortage / currency 四个数。
这种情况下连任务行都没有。

  • 200 加 ok: true,而 operation.state 是 failed,

operation.outcome 的最前面是 upstream_failed|账户余额不足:…
(那句中文原话里就带着"需要多少 / 当前多少 / 还差多少"三个数)。

所以判"到底成没成"一律要两步都看:先看 HTTP 和 ok,
再看 operation.state 与 operation.outcome 的前缀。
只认 HTTP 状态码的程序会漏掉第二种。

钱扣了但机器没开出来,怎么办

#

这种结果一定会被说出来,不会静默成功:

  • 任务变成 failed,outcome 的最前面是稳定错误码,形如

provision_failed|… 或 renew_failed|…(后面接机房的原话)。

  • result.needs_operator 是 true,一眼看得出"要找客服"。
  • 钱不退、账单保持已付。 这和"客户自己支付时撞上同样情况"的处置

一模一样:订单 / 账单 / 服务那几行都留着,由人工在后台处理。
重复提交解决不了问题(那会让账单被扣两次的担心变成真的),
请把 op_id / request_id 提供给我们。

⚠️ 反过来的顺序绝不采用:先开机器再扣钱,成功了却扣不到钱 =
白送一台机器。那是本站最不能出的结果,所以顺序永远是
先查余额 → 再落单 → 再原子扣款结算。

余额只动一次

#

同一个 Idempotency-Key 重放,命中的是上一次那一笔,
扣款函数不会被调第二次。所以"网络超时后重试"请务重用同一个键。

并发上,真正防止超支的是扣款那一步的条件更新
(余额 >= 金额 写在 SQL 里,靠数据库行锁排队),
不是接口这一层"查一下够不够"。所以两个人同时抢同一笔余额时,
只有一个人能扣到,另一个人会拿到余额不足。

配置项:报的是默认配置的价

#

prices[] 里的价是按该商品的默认配置算出来的:
每个可配置项取标记为默认的那一项,没有标记就取第一项。
应答里的 selection 恒为 default,就是在告诉你这一点。

op=order 时你可以用 selection 指定配置({"选项编号": 取值编号})。
那时账单按你选的配置算。可选值和它们的 price_diff 在
GET /v1/products/{id} 的 options 里。

七、写操作的幂等:两把锁

写操作真的会动机器。所以有两层保护,请都用上。

第一层:`Idempotency-Key` 请求头(防重放)

#

同一个 Idempotency-Key 再打一次,回的是上一次那一笔任务,
dispatched 是 false,绝不会重复下发。

Idempotency-Key: 你生成的一个唯一串
  • 键的原文不落库,本站只存它的 sha256 前 32 位,和存密钥一个待遇。
  • 同一个键 + 同一个 op 才算同一笔。换个键就是新的一笔

(所以正常的下一次关机不会被吞掉)。

  • 不带这个头就不做这一层。 把"没带"当成"所有请求互为重复"会把

客户正常的下一次操作吞掉,那是错的。

  • 网络超时之后重试,请用同一个键。这就是它存在的意义。

第二层:重复提交保护(防双击)

#

不管带没带幂等键,同一台服务的同一个操作:

  • 正在处理中(有 queued / running 的任务)→ **409

operation_busy**,响应里 error.detail.op_id 告诉你那一笔的编号,
去轮询它,不要重复提交。

  • 刚刚已经做成(60 秒内 done 过)→ 200,state 是 done,

dispatched 是 false,outcome 里明说"刚刚已经执行过,本次没有重复下发"。

为什么第二种不报错:把"已经关过机了"报成错误,会让你的脚本以为操作失败了,
而事实是目标状态已经达成。那是成功。

  • 同步操作(开通 / 续费)不查这两条,因为它们自己就是幂等的:

已有未付续费账单会复用,订单有库存闸和独立订单号。
真要防重复下单,请带 Idempotency-Key。

并发下的上限(说清楚,不装作没有)

#

那个"在途"判断是"先查后插",两个请求在同一毫秒进来时可能都查不到对方,
于是落两行任务。兜底是幂等键的唯一约束、机房自己的防重复、以及
"开机 / 关机"这个动作本身就幂等。真正会因为并发重复而多花钱的只有开通,
它的兜底是订单号而不是接口这层。所以:要严格防重复,请带幂等键。

八、限流:读一组,写一组

读和写是两组完全独立的桶。 这一点很重要:一次全量对账(几十次读)
不会把你的写额度打光,反过来也一样。

组桶默认额度主体
读密钥60 次 / 分钟每一把 API 密钥
读来源 IP120 次 / 分钟每一个来源 IP
写密钥6 次 / 分钟每一把 API 密钥
写来源 IP30 次 / 分钟每一个来源 IP
  • 为什么写给得这么少:一次写就是一次真的动机器(甚至真的开单)。

"一分钟六次关机"已经比任何真人操作都密。

  • 窗口是 60 秒的滚动窗口:数最近 60 秒里已经发生过的调用次数。
  • 两道闸(主体 + IP)同时生效。撞上哪一道都是 429 加

error.code = rate_limited,响应头里有 Retry-After(单位是秒)。
写操作的 429 文案和读的略有不同,但 code 一样。

  • 撞上了请按 Retry-After 退避,不要在窗口里一直重试。
  • 额度按密钥分开算:一把密钥被限流,不影响同一个账号的另一把密钥,

也不影响别的账号。

  • 具体额度可以由本站管理员调整。所以请不要把 60 或 6 写成常量,

也不要以"每分钟固定 N 次"来设计;按 429 和 Retry-After 自适应。

九、接口:商品

GET/v1/products在售商品列表只读#

在售商品列表,带你这个账号每个周期的价。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>。另外两种写法见第三节
cyclequerystring否monthly / quarterly / semiannually / annually 之一。给了不在这四个里的值,当场 400 加 bad_cycle,不会静默退回月付

注意:cycle 只决定应答里 default_cycle 回显哪一个周期。
每一个商品返回的是它所有有定价的周期的价,不是一个周期的价。
一次全报出来才好对账。

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/products?cycle=annually"

应答(data 里):

字段类型说明
caller对象这次是谁在调,见第十一节
default_cycle字符串你传的 cycle,没传就是 monthly
count数字products 里有多少个商品
products数组商品对象,见下
query_count数字诊断计数,见第十五节

商品对象:

字段类型说明
id数字商品编号
name字符串商品名
description字符串商品说明,最多 2000 字
category对象{ "id": 数字, "name": 字符串 } 商品分组
kind字符串cloud(云产品,自动开通)或 manual(独立产品,人工开通)
kind_label字符串上面那个的中文,例如 云产品(自动开通)
status字符串on_sale 或 off_sale
stock数字库存,-1 表示不限
auto_provision布尔付款后是否自动开通
pricing_cycles数组这个商品真的有定价的周期,固定按 月 / 季 / 半年 / 年 排
prices数组每个周期一项,见下
options数组可配置项,见下

商品列表只列在售的商品。单个商品接口才查得到已下架的。

prices 数组里每一项:

字段类型说明
cycle字符串实际算价用的周期
cycle_label字符串月付 / 季付 / 半年付 / 年付
priced布尔有没有算出价
pricing_note字符串priced 为假时,这里是用中文说明原因(例如"商品未定价")
price字符串或 null实际要收的价,两位小数
list_price字符串或 null折前价
discount_percent字符串或 null这次实际施加的减免百分比
saving字符串或 null减免了多少钱
selection字符串恒为 default,表示这一项价是按默认配置算的
duration_id数字本站内部的周期编号,下单时用
months数字这个周期是几个月
currency字符串三位大写币种代码

priced 为假时,price / list_price / discount_percent / saving
都是 null,并且 selection / duration_id / months / currency
里只有 currency 会出现。请按"可能缺键"来解析。

options 数组里每一项:

字段类型说明
id数字可配置项编号
name字符串名称,例如 内存
required布尔是不是必选
values数组可选值,每项 { "id", "label", "price_diff", "is_default" }

price_diff 是这个选项相对基准价的加价(字符串金额)。本站不返回成本。

应答示例(products 只留第 1 个商品,prices 只留第 1 个周期;
真实应答里会有 count 那么多项,字段名与取值类型一字未改):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "f5d40aed86f34abb",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "default_cycle": "monthly",
        "count": 2,
        "products": [
            {
                "id": 11,
                "name": "香港云服务器 1核1G",
                "description": "测试用商品",
                "category": { "id": 4, "name": "云服务器" },
                "kind": "cloud",
                "kind_label": "云产品(自动开通)",
                "status": "on_sale",
                "stock": -1,
                "auto_provision": true,
                "pricing_cycles": ["monthly", "annually"],
                "prices": [
                    {
                        "cycle": "monthly",
                        "cycle_label": "月付",
                        "priced": true,
                        "pricing_note": "",
                        "price": "85.00",
                        "list_price": "100.00",
                        "discount_percent": "15.00",
                        "saving": "15.00",
                        "selection": "default",
                        "duration_id": 69,
                        "months": 1,
                        "currency": "USD"
                    }
                ],
                "options": [
                    {
                        "id": 21,
                        "name": "内存",
                        "required": true,
                        "values": [
                            { "id": 31, "label": "1G", "price_diff": "0.00", "is_default": true },
                            { "id": 32, "label": "2G", "price_diff": "20.00", "is_default": false }
                        ]
                    }
                ]
            }
        ],
        "query_count": 30
    }
}

应答示例(失败):cycle 给了不在白名单里的值。

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "ce2d9cd1eb58a942",
    "error": {
        "code": "bad_cycle",
        "message": "cycle 只能是 monthly、quarterly、semiannually、annually 之一。"
    }
}

本接口专属的错误:

HTTPcode什么时候
400bad_cyclecycle 不在那四个值里
GET/v1/products/{id}单个商品只读#

一个商品,包含已下架的商品(按编号问就答)。字段和列表接口里的商品对象一样。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>
{id}path数字是商品编号,十进制数字,1 到 18 位
cyclequerystring否取值同上一节。合法值决定应答里的 default_cycle;这个商品没有那个周期的定价时退回 monthly,而且从 default_cycle 看得出来。非法值照样 400 加 bad_cycle

{id} 只接受十进制数字。别的写法(负数、带字母、超长)会落到 404
endpoint_not_found,不会有"参数错误"这种提示。

应答(data 里)有三个键:caller、default_cycle、product
(一个商品对象,字段与列表接口完全一样)、query_count。
没有 count。

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/products/11?cycle=annually"

应答示例(只留第 1 个周期):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "ef2ad845d93f89bf",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "default_cycle": "monthly",
        "product": {
            "id": 11,
            "name": "香港云服务器 1核1G",
            "description": "测试用商品",
            "category": { "id": 4, "name": "云服务器" },
            "kind": "cloud",
            "kind_label": "云产品(自动开通)",
            "status": "on_sale",
            "stock": -1,
            "auto_provision": true,
            "pricing_cycles": ["monthly", "annually"],
            "prices": [
                {
                    "cycle": "monthly",
                    "cycle_label": "月付",
                    "priced": true,
                    "pricing_note": "",
                    "price": "85.00",
                    "list_price": "100.00",
                    "discount_percent": "15.00",
                    "saving": "15.00",
                    "selection": "default",
                    "duration_id": 69,
                    "months": 1,
                    "currency": "USD"
                }
            ],
            "options": []
        },
        "query_count": 27
    }
}

应答示例(失败):编号不存在。

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "24aeaa954beff0cf",
    "error": {
        "code": "not_found",
        "message": "没有这个商品。"
    }
}

本接口专属的错误:

HTTPcode什么时候
404not_found没有这个编号的商品
400bad_cyclecycle 不在那四个值里

十、接口:服务

GET/v1/services服务清单只读#

你的云服务清单,按服务编号从大到小排。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>

这个接口不收任何查询参数。

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/services"

应答(data 里):

字段类型说明
caller对象见第十一节
count数字services 里有多少台
manual_count数字这个账号还有几台独立产品(人工开通那种)不在本接口里
manual_count_note字符串上面那个数字的中文说明
services数组服务对象,见下
query_count数字诊断计数

服务对象:

字段类型说明
id数字服务编号
name字符串服务名,一般是主机名或域名
product对象{ "id": 数字, "name": 字符串 }
status字符串本站数据库里的原始状态值,例如 Active
status_label字符串上面那个的中文,例如 正常
kind字符串本接口只回云产品,所以恒为 cloud
billing_cycle字符串计费周期代码
billing_cycle_label字符串计费周期中文
amount字符串这台服务的金额,两位小数
currency字符串币种代码
reg_date字符串或 null开通日期,Y-m-d
next_due_date字符串或 null下次到期日,Y-m-d
days_to_due数字或 null还有几天到期,按本站服务器当天算,已过期是负数
auto_renew布尔是否自动续费
upstream字符串这台机器挂在哪个上游,例如 v10
upstream_host_id数字或 null上游那边的机器编号。它和 upstream 一起决定这台机器能不能做写操作,见第十二节
ip字符串主 IP,没有就是空串
ipv6字符串IPv6,没有就是空串
assigned_ips字符串附加 IP,多个用逗号分隔,没有就是空串
username字符串登录用户名,没有就是空串
ssh_port数字连接端口
has_password布尔有没有设过密码。本站不返回密码本身
login_block字符串连接信息那几行纯文本,见下
spec对象规格,键是中文名(例如 内存),值是字符串。没有就是空对象
dc字符串机房
os字符串操作系统
notes字符串备注,最多 2000 字
caution字符串注意事项,最多 2000 字
pending_renew_invoice对象或 null未付的续费账单,见下

pending_renew_invoice 不是 null 时:

字段类型说明
id数字账单编号
amount字符串金额
currency字符串币种
due_date字符串或 null到期日 Y-m-d

一台服务可能有多张未付续费账单,本站只给最新那一张,
这样列表和详情两个接口的值永远一致。

login_block 是给人看的多行纯文本,和你在服务详情页点"复制全部"
拿到的那几行是同一份(行与行之间是 \n)。它不含密码。

应答示例(只留第 1 台):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "22796eee88c4ada6",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "count": 3,
        "manual_count": 1,
        "manual_count_note": "这个客户还有 1 台独立产品(人工开通),不在本接口里,请在会员中心「独立产品」页查看。",
        "services": [
            {
                "id": 2003,
                "name": "over.example.com",
                "product": { "id": 12, "name": "独立服务器托管" },
                "status": "Suspended",
                "status_label": "已暂停",
                "kind": "cloud",
                "billing_cycle": "monthly",
                "billing_cycle_label": "月付",
                "amount": "50.00",
                "currency": "USD",
                "reg_date": "2026-08-01",
                "next_due_date": "2026-10-07",
                "days_to_due": -3,
                "auto_renew": false,
                "upstream": "v10",
                "upstream_host_id": 9003,
                "ip": "5.6.7.8",
                "ipv6": "",
                "assigned_ips": "5.6.7.9",
                "username": "root",
                "ssh_port": 22,
                "has_password": false,
                "login_block": "IP: 5.6.7.8\n用户名: root\n端口: 22\n附加 IP: 5.6.7.9",
                "spec": { "访问地址": "5.6.7.8" },
                "dc": "",
                "os": "",
                "notes": "",
                "caution": "注意备份",
                "pending_renew_invoice": null
            }
        ],
        "query_count": 16
    }
}

本接口专属的错误:

HTTPcode什么时候
404not_found没有这个账号(正常情况下不会出现)
GET/v1/services/{id}一台服务只读#

一台服务。字段与上一节的服务对象完全一样,
只是 data 里是 service 一个对象,另外带 caller 和 query_count。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>
{id}path数字是服务编号,十进制数字,1 到 18 位

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/services/2001"

应答示例(片段,完整字段同上表):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "dda501e01e357a23",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "service": {
            "id": 2001,
            "name": "hk-01.example.com",
            "product": { "id": 11, "name": "香港云服务器 1核1G" },
            "status": "Active",
            "status_label": "正常",
            "kind": "cloud",
            "billing_cycle": "monthly",
            "billing_cycle_label": "月付",
            "amount": "85.00",
            "currency": "USD",
            "reg_date": "2026-09-01",
            "next_due_date": "2026-11-09",
            "days_to_due": 30,
            "auto_renew": false,
            "upstream": "v10",
            "upstream_host_id": 9001,
            "ip": "1.2.3.4",
            "ipv6": "",
            "assigned_ips": "",
            "username": "root",
            "ssh_port": 22022,
            "has_password": true,
            "login_block": "IP: 1.2.3.4\n用户名: root\n端口: 22022",
            "spec": {
                "内存": "1G",
                "CPU": "1核",
                "访问地址": "1.2.3.4",
                "操作系统": "CentOS 8",
                "机房": "HK"
            },
            "dc": "HK",
            "os": "CentOS-8-Stream-x64",
            "notes": "测试机",
            "caution": "",
            "pending_renew_invoice": null
        },
        "query_count": 15
    }
}

应答示例(失败):这台服务不存在,或者它不属于你这个账号。

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "b57dea55082cace5",
    "error": {
        "code": "not_found",
        "message": "没有这个服务,或者它不属于这个账号。"
    }
}

本接口专属的错误:

HTTPcode什么时候
404not_found这台服务不存在,或者它不属于你这个账号(两种情况同一个应答)

十一、接口:账户

GET/v1/balance账户余额只读#

余额。余额的唯一权威是账号上的那个余额值,本站不会拿历史账单再汇总一遍。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/balance"

应答(data 里):

字段类型说明
caller对象见下
balance对象见下
query_count数字诊断计数

balance:

字段类型说明
client_id数字账号编号
amount字符串余额,两位小数
currency字符串币种代码
display_currency字符串页面上会用什么币种显示。这不是换算后的金额

应答示例:

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "6701dfb649b5364c",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "balance": {
            "client_id": 12,
            "amount": "123.45",
            "currency": "USD",
            "display_currency": "usd"
        },
        "query_count": 13
    }
}

display_currency 只是"页面上会怎么显示",不是换算后的金额。
换算汇率会变,接口回原值加币种,换算请你自己做。

本接口专属的错误:

HTTPcode什么时候
404not_found没有这个账号(正常情况下不会出现)

caller 对象

#

每个成功应答里都有 caller,告诉你这次是谁在调。
用密钥调用时是这些字段:

字段类型说明
type字符串使用密钥时是 downstream
type_label字符串上面那个的中文
client_id数字你这把密钥所属的账号
client_name字符串账号名(没填名字时是邮箱)
scope字符串使用密钥时是 client,意思是"范围就是这一个账号"
key_prefix字符串密钥的前 12 个字符,用来让你确认"是哪一把"
key_label字符串创建这把密钥时填的名称

这里永远只有前缀,不会有密钥原文,也不会有完整哈希。

十二、接口:服务操作(写)

这一族只有一个路径,用请求体里的 op 区分要做什么。
这样加一个操作不需要你改 URL。

POST/v1/services/{service_id}/ops发起一个操作写操作#

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>
Idempotency-Keyheaderstring否幂等键,见第七节。强烈建议写
service_idpath数字是服务编号,十进制数字,1 到 18 位。必须是你自己账号的
opbodystring是要做什么。取值见下面那张表
product_idbody数字op=order 时必填商品编号
cyclebodystring否计费周期,默认 monthly,取值同商品接口
selectionbody对象否{"选项编号": 取值编号},只给 op=order 用
idempotency_keybodystring否幂等键也可以写在请求体里(不推荐,见第七节)

请求体是 JSON 对象。上限 64 KB。JSON 解不出来时会退一步按表单编码解析。

请求示例(四种语言,都能直接跑;写操作比读多一个幂等键头):

curl -s -X POST \
     -H "Authorization: Bearer 你的API密钥" \
     -H "Idempotency-Key: demo-0001" \
     -H "Content-Type: application/json" \
     -d '{"op":"power_off"}' \
     "https://<站点>/api.php/v1/services/2001/ops"
<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';

$ch = curl_init($api . '/v1/services/2001/ops');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $key,
        'Content-Type: application/json',
        'Idempotency-Key: demo-0001',
    ],
    CURLOPT_POSTFIELDS     => json_encode(['op' => 'power_off']),
    CURLOPT_TIMEOUT        => 30,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$j = json_decode((string) $body, true);
if (empty($j['ok'])) {
    throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
// 202 = 已受理,去轮询;200 且 async=false = 已经出结果
echo $j['data']['operation']['state'], ' ', $j['data']['poll'], "\n";
import json
import urllib.request

API = "https://<站点>/api.php"
KEY = "你的API密钥"

req = urllib.request.Request(
    API + "/v1/services/2001/ops",
    data=json.dumps({"op": "power_off"}).encode("utf-8"),
    headers={
        "Authorization": "Bearer " + KEY,
        "Content-Type": "application/json",
        "Idempotency-Key": "demo-0001",
    },
    method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
    data = json.loads(resp.read().decode("utf-8"))

op = data["data"]["operation"]
print(op["state"], op["state_label"], data["data"]["poll"])
const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';

const res = await fetch(API + '/v1/services/2001/ops', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + KEY,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'demo-0001',
  },
  body: JSON.stringify({ op: 'power_off' }),
});
const body = await res.json();
if (!body.ok) {
  throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
}
const op = body.data.operation;
console.log(res.status, op.state, op.state_label, body.data.poll);

应答形状(所有操作共用一种形状):

字段类型说明
caller对象见第十一节
operation对象这一笔任务,字段见下
dispatched布尔这次真的下发了吗。false = 命中幂等或去重保护,没有重复下发
async布尔是不是"先回 202,再在后台跑完"
poll字符串轮询这一笔的路径(不含域名,跟着你的入口形状走)
note字符串这个操作的说明
result对象业务结果。只有真的产生了东西时才有这个键(账单 / 订单 / 服务)

operation 对象:

字段类型说明
op_id字符串任务编号,op_ 加 32 位十六进制,128 位随机数,猜不出来
operation字符串就是 op 的取值
label字符串这个操作的中文名
kind字符串service 或 order
state字符串queued / running / done / failed
state_label字符串上面那个的中文
caller字符串downstream
service_id数字服务编号(订单类操作是 0)
order_id数字订单编号(服务类操作是 0)
submitted_at字符串或 null受理时间,Y-m-d H:i:s
started_at字符串或 null开始下发的时间
finished_at字符串或 null结束时间
outcome字符串人能看懂的一句话:失败了就是机房的原话。失败时最前面是稳定错误码 + 全角竖线,形如 provision_failed|…
want_state字符串想要到的机器状态:on / off / 空
upstream_state字符串回读到的机器状态原值,例如 Running
settled布尔或 null机器到没到目标状态。null = 这个操作没有可回读的目标状态
settled_note字符串上面那个字段的中文说明

state 的语义请务必看准:

  • queued / running = 已开始,还没结果。继续轮询。
  • done = 机房受理了。它不等于机器已经到了目标状态,

所以还要看 settled。

  • failed = 机房拒绝了 / 抛异常了 / 超时了。outcome 里是机房的原话,

而且最前面带稳定错误码。

settled 的意义:上游的电源接口是"同步返回受理结果"的,它回成功只说明
收下了这条指令。本站会再回读一次机器状态放进 upstream_state,
并用 settled 回答"到没到"。刚下发时 settled 常常是 false,
过几秒再轮询一次就有了。所以"到底关掉没有"要看这里,不要看 HTTP 状态码。

result 里可能出现的业务结果:

result 里的键什么时候有内容
invoice开通 / 续费id / status / amount / currency / due_date
balance开通 / 续费charged(这一笔实际扣了多少,按余额差算)、before、after
paid_by开通 / 续费paid(这次扣的)、already_paid(并发下已被别的请求付掉)、already_renewed(这张续费账单早就处理过,本次没扣)
payments_gateway开通 / 续费恒为 credit,表示走的是账户余额
pay_note开通 / 续费一句中文说明
order开通id(订单号)、total、service_id(开出来的服务编号,没开出来就是 0)
provisioning开通ok(机器开出来了吗)、msg、note
renew续费upstream_done、upstream_skipped、upstream_msg、note
reused_existing续费这张续费账单是不是复用了已有的那张未付账单
needs_operator扣款成功但机器那一段没做成恒为 true。看到它就别再重复提交,把 op_id 给我们

⚠️ balance 那一项是按余额差算出来的,不是把账单金额再算一遍。
两处推导同一个数正是本项目最贵的 bug 来源,所以这里刻意只认余额的前后差。

应答示例(成功、异步受理,HTTP 202):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "7d21b0c9a4e35f18",
    "data": {
        "caller": {
            "type": "downstream",
            "type_label": "下游对接方(客户密钥)",
            "client_id": 12,
            "client_name": "张三",
            "scope": "client",
            "key_prefix": "idc_live_aaa",
            "key_label": "张三的下游脚本"
        },
        "operation": {
            "op_id": "op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
            "operation": "power_on",
            "label": "开机",
            "kind": "service",
            "state": "queued",
            "state_label": "已受理,等待下发",
            "caller": "downstream",
            "service_id": 2001,
            "order_id": 0,
            "submitted_at": "2026-10-10 14:20:31",
            "started_at": null,
            "finished_at": null,
            "outcome": "已受理,正在下发到机房。",
            "want_state": "on",
            "upstream_state": "",
            "settled": null,
            "settled_note": "机房还没受理完成,请继续轮询。"
        },
        "dispatched": true,
        "async": true,
        "poll": "/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
        "note": "把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。"
    }
}

应答示例(失败):同一台服务的同一个操作正在处理中。

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "1a7f4c9e2b6d8035",
    "error": {
        "code": "operation_busy",
        "message": "这台服务的「关机」正在处理中,请用返回的 op_id 轮询结果,不要重复提交。",
        "detail": {
            "op_id": "op_4b8e1d0f6a2c9375e4d81f0b7a3c692e",
            "state": "queued"
        }
    }
}

本接口专属的错误:

HTTPcode什么时候
400bad_requestop 不认识或没带;op=order 没带 product_id;selection 结构不对
400bad_cyclecycle 不在那四个值里
400service_not_enabled这台服务是独立产品(没绑机房)、没绑机器、或者机房信息不完整
402insufficient_balance账户余额不足(只可能由 op=order / op=renew 触发)。error.detail 里带 balance / required / shortage / currency
403operation_not_allowed这个操作不对客户密钥开放
404not_found这台服务不存在,或者不属于你这个账号
409operation_busy同一台服务的同一个操作正在处理中
503operation_store_unavailable写操作功能还没启用

⚠️ 还有一类失败不在这里:请求本身没问题、但业务没做成
(扣款失败 / 机器没开出来 / 机房那一段没完成 ✓)。
那时 HTTP 是 200、ok 是 true,而 operation.state 是 failed,
稳定错误码在 operation.outcome 的最前面。见第五节末尾那张表。

可发起的操作这张表由代码里的操作表现读生成
op名称权限是否异步说明
sync同步信息下游密钥也可以用异步(先回 202,再轮询)让本地服务记录跟上机房的最新状态。只读操作,不会动机器。
power_on开机下游密钥也可以用异步(先回 202,再轮询)把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。
power_off关机下游密钥也可以用异步(先回 202,再轮询)把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。
power_reboot重启下游密钥也可以用异步(先回 202,再轮询)重启机器。重启期间状态不变,所以只能确认机房已受理。
vncVNC 控制台地址仅本站内部系统同步(当场就有结果)取一次性的控制台地址。只对本站内部系统开放,不对下游密钥开放,且响应里不含任何密码。
order开通(下订单并用余额支付)下游密钥也可以用同步(当场就有结果)按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。
renew续费(扣余额)下游密钥也可以用同步(当场就有结果)为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。余额不足会直接拒绝,不会开单、不会续期。
op=sync同步信息只读异步#

让本地服务记录跟上机房的最新状态。只读操作,不会动机器。

请求体:

{"op": "sync"}

同步(async 是 true),先回 202,去轮询任务。

op=power_on开机写操作异步#

把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。

请求体:

{"op": "power_on"}

want_state 是 on,所以任务里会给出 settled。异步,先回 202。

op=power_off关机写操作异步#

把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。

请求体:

{"op": "power_off"}

want_state 是 off,所以任务里会给出 settled。异步,先回 202。

op=power_reboot重启写操作异步#

重启机器。重启期间状态不变,所以只能确认机房已受理。

请求体:

{"op": "power_reboot"}

want_state 是空的,所以 settled 是 null,settled_note 会说明
"这个操作没有可回读的目标状态"。异步,先回 202。

op=order开通(下订单并用余额支付)写操作#

按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,
不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。

请求体:

{
    "op": "order",
    "product_id": 11,
    "cycle": "monthly",
    "selection": { "21": 31, "22": 33 }
}
  • product_id 必填。商品编号从 GET /v1/products 拿。
  • cycle 默认 monthly。写错当场 400 加 bad_cycle。
  • selection 是 {"选项编号": 取值编号} 的扁平对象,键和值都必须是数字。

不给就用该商品的默认配置。可选值从 GET /v1/products/{id} 的
options 里拿(默认配置的编号也印在那个应答的 values[].is_default 上)。

  • 这个操作不要求这台服务绑了机器,所以独立产品(人工开通)也能下单。
  • 钱不够就什么都别想发生:余额不足时当场 402

insufficient_balance,订单、账单、余额一个都不会动。

  • 同步:当场就有结果(HTTP 200)。result 里有订单号、已付账单、

这一笔扣了多少、以及机器那一步成没成。

应答示例(成功片段,机器也开出来了):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "3c9b2a17e5d4086f",
    "data": {
        "operation": {
            "op_id": "op_5e1a9c3d7b0f2486a1d95c3f8b2e704d",
            "operation": "order",
            "label": "开通(下订单并用余额支付)",
            "kind": "order",
            "state": "done",
            "state_label": "机房已受理",
            "caller": "downstream",
            "service_id": 2600,
            "order_id": 1,
            "submitted_at": "2026-10-10 14:31:02",
            "started_at": "2026-10-10 14:31:02",
            "finished_at": "2026-10-10 14:31:05",
            "outcome": "已用余额支付 85.00,开通完成。",
            "want_state": "",
            "upstream_state": "",
            "settled": null,
            "settled_note": "机房已受理。这个操作没有可回读的目标状态,请到会员中心核对。"
        },
        "dispatched": true,
        "async": false,
        "poll": "/v1/operations/op_5e1a9c3d7b0f2486a1d95c3f8b2e704d",
        "note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。",
        "result": {
            "invoice": {
                "id": 7001,
                "status": "paid",
                "amount": "85.00",
                "currency": "USD",
                "due_date": "2026-10-17"
            },
            "balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
            "paid_by": "paid",
            "payments_gateway": "credit",
            "pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
            "order": { "id": 1, "total": "85.00", "service_id": 2009 },
            "provisioning": {
                "ok": true,
                "msg": "",
                "note": "本站既有的开通流程已经处理完这一步。"
            }
        }
    }
}

应答示例(失败一:余额不足,什么都没发生):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "d8b09e7157eb15d7",
    "data": {
        "operation": {
            "op_id": "op_97256094a6d98b35fdb990c31b24f243",
            "operation": "order",
            "label": "开通(下订单并用余额支付)",
            "kind": "order",
            "state": "failed",
            "state_label": "失败",
            "caller": "downstream",
            "service_id": 2600,
            "order_id": 0,
            "submitted_at": "2026-10-10 15:19:16",
            "started_at": "2026-10-10 15:19:16",
            "finished_at": "2026-10-10 15:19:16",
            "outcome": "upstream_failed|账户余额不足:这一笔需要 $850.00,当前余额 $123.45,还差 $726.55。请先充值。",
            "want_state": "",
            "upstream_state": "",
            "settled": null,
            "settled_note": "操作失败,机器状态没有改变。"
        },
        "dispatched": true,
        "async": false,
        "poll": "/v1/operations/op_97256094a6d98b35fdb990c31b24f243",
        "note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。"
    }
}

⚠️ 同一种情况也可能以一个同步的 402 出现:
error.code = insufficient_balance,error.detail 带
balance / required / shortage / currency。
两种形状都要认(见第六节)。

应答示例(失败二:钱扣了但机器没开出来):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "f3495ac670544ea9",
    "data": {
        "operation": {
            "op_id": "op_90eb8f225c2fbd50fb34c3cdd1bd789b",
            "operation": "order",
            "label": "开通(下订单并用余额支付)",
            "kind": "order",
            "state": "failed",
            "state_label": "失败",
            "caller": "downstream",
            "service_id": 2600,
            "order_id": 1,
            "submitted_at": "2026-10-10 15:20:21",
            "started_at": "2026-10-10 15:20:21",
            "finished_at": "2026-10-10 15:20:21",
            "outcome": "provision_failed|已用余额支付 $85.00,剩余余额 $38.45。 但机器没有开出来(开通流程没有返回服务号)。账单已按余额扣款、订单和服务记录都已保留,请到后台核对,不要重复提交。",
            "want_state": "",
            "upstream_state": "",
            "settled": null,
            "settled_note": "操作失败,机器状态没有改变。"
        },
        "dispatched": true,
        "async": false,
        "poll": "/v1/operations/op_90eb8f225c2fbd50fb34c3cdd1bd789b",
        "note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。",
        "result": {
            "invoice": {
                "id": 7004,
                "status": "paid",
                "amount": "85.00",
                "currency": "USD",
                "due_date": "2026-10-13"
            },
            "balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
            "paid_by": "paid",
            "payments_gateway": "credit",
            "pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
            "order": { "id": 1, "total": "85.00", "service_id": 0 },
            "provisioning": {
                "ok": false,
                "msg": "",
                "note": "钱已经按账单收了、账单已付清,但机器没开出来。订单和服务记录都留着,请到后台核对(不要重复提交)。"
            },
            "needs_operator": true
        }
    }
}

⚠️ 最后这一种情况钱不退、账单保持已付。请把 op_id 提供给我们,
不要重复提交 —— 重复提交解决不了它,只会多一层对账的麻烦。

应答示例(失败三:product_id 没带,请求本身就不对):

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "8e2d5b91c4a73f60",
    "error": {
        "code": "bad_request",
        "message": "缺少 product_id(商品编号)。请在请求体里带上它。"
    }
}
op=renew续费(扣余额)写操作#

为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。
余额不足会直接拒绝,不会开单、不会续期。扣款成功后由既有的续费流程
顺延到期日并同步机房。

请求体:

{"op": "renew"}
  • cycle 可以不写。写了就必须等于这台服务当前的计费周期,

换周期要拿定价重算一笔价,那是第三处算价,本版本不做。
要换周期请到会员中心或联系客服。

  • 这个操作不要求这台服务绑了机器(付了钱还没开出来那种正好最需要续费)。
  • 账号邮箱还没验证时不能续费(和客户中心同一条前置条件),会 400。
  • 钱不够时当场 402 insufficient_balance,不会留下任何账单。
  • 那张续费账单如果早就处理过(客户自己在会员中心付掉了),

本次不会重复扣款,paid_by 是 already_renewed、balance.charged 是 0.00。

  • 机房那一段按设置被跳过时,本地到期日照样顺延,

result.renew.upstream_skipped 是 true。

  • 同步:当场就有结果(HTTP 200)。

应答示例(成功片段,机房也同步了):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "b41e7c0a95d3286f",
    "data": {
        "operation": {
            "op_id": "op_1c7d0b4a9e2f8563d0a71c5b8e3f9642",
            "operation": "renew",
            "label": "续费(扣余额)",
            "kind": "service",
            "state": "done",
            "state_label": "机房已受理",
            "caller": "downstream",
            "service_id": 2001,
            "order_id": 0,
            "submitted_at": "2026-10-10 14:33:40",
            "started_at": "2026-10-10 14:33:40",
            "finished_at": "2026-10-10 14:33:44",
            "outcome": "已生成续费账单 已用余额支付 85.00,续期成功,到期日 2026-11-09 → 2026-12-09。机房:已同步。",
            "want_state": "",
            "upstream_state": "",
            "settled": null,
            "settled_note": "机房已受理。这个操作没有可回读的目标状态,请到会员中心核对。"
        },
        "dispatched": true,
        "async": false,
        "poll": "/v1/operations/op_1c7d0b4a9e2f8563d0a71c5b8e3f9642",
        "note": "为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。余额不足会直接拒绝,不会开单、不会续期。",
        "result": {
            "invoice": {
                "id": 7004,
                "status": "paid",
                "amount": "85.00",
                "currency": "USD",
                "due_date": "2026-11-09"
            },
            "balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
            "paid_by": "paid",
            "payments_gateway": "credit",
            "pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
            "reused_existing": false,
            "renew": {
                "upstream_done": true,
                "upstream_skipped": false,
                "upstream_msg": "",
                "note": "本地到期日已经顺延,机房也已同步。"
            }
        }
    }
}

应答示例(失败:钱扣了、本地也续上了,但机房那一段没完成):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "9f4c2a70b1e5d386",
    "data": {
        "operation": {
            "op_id": "op_3a8f1c60d9b2e475a0c3f81b6d2e9507",
            "operation": "renew",
            "label": "续费(扣余额)",
            "kind": "service",
            "state": "failed",
            "state_label": "失败",
            "caller": "downstream",
            "service_id": 2001,
            "order_id": 0,
            "submitted_at": "2026-10-10 14:40:02",
            "started_at": "2026-10-10 14:40:02",
            "finished_at": "2026-10-10 14:40:09",
            "outcome": "renew_failed|已用余额支付 85.00,续期成功,到期日 2026-11-09 → 2026-12-09。机房侧尚未完成。 到期日已经顺延、余额已按账单扣款,不需要重复提交;机房那一段需要人工跟进。",
            "want_state": "",
            "upstream_state": "",
            "settled": null,
            "settled_note": "操作失败,机器状态没有改变。"
        },
        "dispatched": true,
        "async": false,
        "poll": "/v1/operations/op_3a8f1c60d9b2e475a0c3f81b6d2e9507",
        "result": {
            "renew": {
                "upstream_done": false,
                "upstream_skipped": false,
                "upstream_msg": "机房侧尚未完成",
                "note": "本地到期日已经顺延;机房那一段还没完成,需要人工跟进。"
            },
            "needs_operator": true
        }
    }
}

本接口(op=order / op=renew)专属的错误:

HTTPcode什么时候
402insufficient_balance账户余额不足。什么都没发生,不会下单、不会开单、不会扣款
400bad_request缺 product_id;selection 结构不对;cycle 和当前周期不一致;邮箱未验证
400bad_cyclecycle 不在那四个值里
404not_found这台服务不存在,或者不属于你这个账号
200payment_failed(在 operation.outcome 里)扣款那一步失败
200provision_failed(在 operation.outcome 里)钱扣了、账单已付清,但机器没开出来。result.needs_operator 是 true
200renew_failed(在 operation.outcome 里)钱扣了、到期日已顺延,但机房那一段没完成。result.needs_operator 是 true

VNC 控制台(`op=vnc`):不对下游开放

#

op=vnc 在本站的操作表里,但它只对本站内部系统开放。
用你的密钥调它,会拿到 403 加 operation_not_allowed,
响应里没有任何地址、密码或令牌。

原因是 VNC 不是"一次操作"而是一张进门条:它把看得见屏幕的权限交出去,
一般还能进 BIOS、改启动项。那比关机大得多,不适合做成一个脚本里的一步。

需要控制台请到会员中心的「服务详情 → 配置与操作」打开,
那里是登录之后的一对一页面,有会话、有审计。

十三、接口:任务(轮询)

异步写操作回一个任务号,你拿它来查结果。

GET/v1/operations我最近的任务只读#

你这一个账号最近的任务,最多 50 笔,按编号从大到小排。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/operations"

应答(data 里):

字段类型说明
caller对象见第十一节
count数字operations 里有多少笔
note字符串一句说明(只列最近 50 笔、单笔怎么查)
operations数组任务对象数组,字段与单笔查询里的 operation 完全一样

只列你自己账号的任务。别人的任务你查不到,也不会串进来。

本接口专属的错误:

HTTPcode什么时候
503operation_store_unavailable写操作功能还没启用
GET/v1/operations/{op_id}轮询一笔任务只读#

拿 POST /v1/services/{service_id}/ops 应答里的 poll(或者 op_id)来查。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>
op_idpathstring是任务编号,4 到 40 个字母数字下划线

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385"

应答形状和发起操作时一样(caller / operation / dispatched / async /
poll / note),只是 dispatched 是 false(轮询本身不下发任何东西)。

应答示例(成功片段,异步操作跑完之后):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "5f8a1c3e70b9d246",
    "data": {
        "operation": {
            "op_id": "op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
            "operation": "power_off",
            "label": "关机",
            "kind": "service",
            "state": "done",
            "state_label": "机房已受理",
            "caller": "downstream",
            "service_id": 2001,
            "order_id": 0,
            "submitted_at": "2026-10-10 14:20:31",
            "started_at": "2026-10-10 14:20:32",
            "finished_at": "2026-10-10 14:20:36",
            "outcome": "指令已下发",
            "want_state": "off",
            "upstream_state": "Stopped",
            "settled": true,
            "settled_note": "机器已经到目标状态。"
        },
        "dispatched": false,
        "async": false,
        "poll": "/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
        "note": "把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。"
    }
}

settled 还是 false 的时候长这样(很常见,过几秒再查一次):

{
    "state": "done",
    "state_label": "机房已受理",
    "upstream_state": "Running",
    "settled": false,
    "settled_note": "机房已受理,但机器还没到目标状态(off)。刚下发时很常见,请过几秒再轮询一次。"
}

应答示例(失败):编号不存在,或者不属于你这个账号。

{
    "ok": false,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "2d9e6b40f1a83c57",
    "error": {
        "code": "not_found",
        "message": "没有这个操作编号,或者它不属于这个账号。"
    }
}

本接口专属的错误:

HTTPcode什么时候
404not_found没有这个操作编号,或者它不属于这个账号(同一个应答)
503operation_store_unavailable写操作功能还没启用

还有一个兜底要知道:进程中途结束的任务会卡在 running,
本站每次受理和轮询时会顺手把超过 10 分钟没动静的判成 failed,
outcome 写的是"操作超时,结果未知,请到会员中心或联系客服核对机器状态"。
所以轮询到 failed 时请到会员中心核对机器真实状态,不要直接当成没发生。

GET/v1/operations/catalog我这一族能做哪些操作只读#

这份清单是从代码的操作表现读的,所以它永远和你实际能调的东西一致。
建议启动时拉一次,用它决定界面上给不给某个按钮。

参数:

名称位置类型必填说明
Authorizationheaderstring是Bearer <你的密钥>

请求示例:

curl -s -H "Authorization: Bearer 你的API密钥" \
     "https://<站点>/api.php/v1/operations/catalog"

应答(data 里):

字段类型说明
caller对象见第十一节
note字符串一句说明(op 取值就是 operation 这一列)
operations数组每一项见下

operations 数组里每一项:

字段类型说明
operation字符串op 的取值
label字符串中文名
method字符串恒为 POST
path字符串恒为 /v1/services/{service_id}/ops
allowed布尔你这把密钥能不能调它。vnc 在下游密钥下是 false
dangerous布尔会不会真的动机器或动钱
poll字符串去哪里轮询。同步操作是空串
description字符串一句话说明

应答示例(operations 只留第 1 项):

{
    "ok": true,
    "api": "idcb-public-api",
    "version": "v1",
    "request_id": "a3f7d120c8b64e95",
    "data": {
        "note": "op 取值就是 operation 这一列。发起操作用 POST /v1/services/{service_id}/ops,请求体 {\"op\":\"...\"}。",
        "operations": [
            {
                "operation": "sync",
                "label": "同步信息",
                "method": "POST",
                "path": "/v1/services/{service_id}/ops",
                "allowed": true,
                "dangerous": false,
                "poll": "GET /v1/operations/{op_id}",
                "description": "让本地服务记录跟上机房的最新状态。只读操作,不会动机器。"
            }
        ]
    }
}

本接口专属的错误:

HTTPcode什么时候
503operation_store_unavailable写操作功能还没启用

十四、完整可跑的例子

把 <站点> 换成本站的 API 域名,把 你的API密钥 换成你创建的那把密钥。
下面的代码都是完整可跑的,只依赖语言自带的库。

curl

#
# 入口:两种写法都可以,第二种在服务器有 try_files 时更保险
API="https://<站点>/api.php"
BASE="https://<站点>/api.php"
KEY="你的API密钥"

# 1) 商品列表,带你这个账号每个周期的价
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products"

# 2) 只看年付(cycle 写错会 400 bad_cycle)
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products?cycle=annually"

# 3) 一个商品
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products/11"

# 4) 服务清单
curl -s -H "Authorization: Bearer $KEY" "$API/v1/services"

# 5) 一台服务
curl -s -H "Authorization: Bearer $KEY" "$API/v1/services/2001"

# 6) 余额
curl -s -H "Authorization: Bearer $KEY" "$API/v1/balance"

# 7) 排障:连状态码和响应头一起看
curl -s -D - -o /dev/null -H "Authorization: Bearer $KEY" "$API/v1/balance"

# 8) 服务器把 /api.php/v1/... 判成 404 时换这种形状(不依赖服务器配置)
curl -s -H "Authorization: Bearer $KEY" "$BASE?path=/v1/products"

# 9) 关机:写操作先回 202,拿 poll 去轮询(-i 能看到状态码)
curl -s -i -X POST -H "Authorization: Bearer $KEY" \
     -H "Idempotency-Key: demo-0001" -H "Content-Type: application/json" \
     -d '{"op":"power_off"}' "$API/v1/services/2001/ops"

# 10) 轮询那一笔(op_id 换成上一步回给你的那个)
curl -s -H "Authorization: Bearer $KEY" "$API/v1/operations/op_00000000000000000000000000000000"

# 11) 能力清单:我这一族能做哪些操作,哪些对下游开放
curl -s -H "Authorization: Bearer $KEY" "$API/v1/operations/catalog"

# 12) 开通:下订单并从账户余额扣款(余额不足会 402,什么都别想发生)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"op":"order","product_id":11,"cycle":"monthly"}' "$API/v1/services/2600/ops"

# 13) 续费:开续费账单并扣余额(金额取服务自己的续费价)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"op":"renew"}' "$API/v1/services/2001/ops"

注意不要用 ?apikey= 那种写法把密钥放进 URL。

PHP

#
<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';

function api_get($url, $key)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $key],
        CURLOPT_TIMEOUT        => 20,
    ]);
    $body = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $j = json_decode((string) $body, true);
    if (!is_array($j)) {
        throw new RuntimeException('不是 JSON,HTTP ' . $code);
    }
    if (empty($j['ok'])) {
        // 认 code,不要认 message
        throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
    }
    return $j;
}

function api_post($url, $key, array $payload, $idem = '')
{
    $h = ['Authorization: Bearer ' . $key, 'Content-Type: application/json'];
    if ($idem !== '') { $h[] = 'Idempotency-Key: ' . $idem; }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => $h,
        CURLOPT_POSTFIELDS     => json_encode($payload),
        CURLOPT_TIMEOUT        => 30,
    ]);
    $body = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $j = json_decode((string) $body, true);
    if (!is_array($j)) {
        throw new RuntimeException('不是 JSON,HTTP ' . $code);
    }
    if (empty($j['ok'])) {
        throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
    }
    return $j;
}

// 1) 商品列表:每个商品每个周期的价
foreach (api_get($api . '/v1/products', $key)['data']['products'] as $p) {
    foreach ($p['prices'] as $pr) {
        if (empty($pr['priced'])) { continue; }
        echo $p['name'], ' ', $pr['cycle_label'], ' 收 ', $pr['price'],
             '(折前 ', $pr['list_price'], ' 减免 ', $pr['discount_percent'], "%)\n";
    }
}

// 2) 服务清单
$svc = api_get($api . '/v1/services', $key)['data'];
echo '云服务 ', $svc['count'], ' 台,另有独立产品 ', $svc['manual_count'], " 台\n";

// 3) 一台服务
$one = api_get($api . '/v1/services/2001', $key)['data']['service'];
echo $one['name'], ' 状态 ', $one['status_label'],
     ' 到期 ', (string) $one['next_due_date'], "\n";

// 4) 余额(金额是字符串,别用浮点累加)
$bal = api_get($api . '/v1/balance', $key)['data']['balance'];
echo '余额 ', $bal['amount'], ' ', $bal['currency'], "\n";

// 5) 关机:202 = 已受理,去轮询任务
$r = api_post($api . '/v1/services/2001/ops', $key, ['op' => 'power_off'], 'demo-0001');
$op = $r['data']['operation'];
echo '任务 ', $op['op_id'], ' 状态 ', $op['state_label'], "\n";

// 6) 轮询到终态
$opId = $op['op_id'];
for ($i = 0; $i < 10; $i++) {
    sleep(2);
    $t = api_get($api . '/v1/operations/' . rawurlencode($opId), $key)['data']['operation'];
    if ($t['state'] === 'done' || $t['state'] === 'failed') {
        echo $t['state_label'], ' / 机器状态 ', $t['upstream_state'],
             ' / 到位 ', var_export($t['settled'], true), "\n";
        break;
    }
}

Python

#
import json
import time
import urllib.request
import urllib.error

API = "https://<站点>/api.php"
KEY = "你的API密钥"


def call(path, payload=None, idem="", method=None):
    url = API + path
    headers = {"Authorization": "Bearer " + KEY}
    data = None
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
        if idem:
            headers["Idempotency-Key"] = idem
    req = urllib.request.Request(url, data=data, headers=headers,
                                 method=method or ("POST" if data else "GET"))
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            body = resp.read().decode("utf-8")
            status = resp.status
    except urllib.error.HTTPError as e:          # 4xx / 5xx 也带 JSON 信封
        body = e.read().decode("utf-8")
        status = e.code

    data = json.loads(body)
    if not data.get("ok"):
        # 认 code,不要认 message
        raise RuntimeError("接口报错 %s HTTP %s" % (data["error"]["code"], status))
    return status, data


# 1) 商品列表
for p in call("/v1/products")[1]["data"]["products"]:
    for pr in p["prices"]:
        if not pr.get("priced"):
            continue
        print("%s %s 收 %s(折前 %s 减免 %s%%)" % (
            p["name"], pr["cycle_label"], pr["price"],
            pr["list_price"], pr["discount_percent"]))

# 2) 服务清单
svc = call("/v1/services")[1]["data"]
print("云服务 %d 台,另有独立产品 %d 台 · %s" % (
    svc["count"], svc["manual_count"], svc["manual_count_note"]))

# 3) 一台服务
one = call("/v1/services/2001")[1]["data"]["service"]
print("%s 状态 %s 到期 %s" % (one["name"], one["status_label"], one["next_due_date"]))

# 4) 余额(金额是字符串,别用浮点累加)
bal = call("/v1/balance")[1]["data"]["balance"]
print("余额 %s %s" % (bal["amount"], bal["currency"]))

# 5) 关机:202 = 已受理,去轮询任务
status, r = call("/v1/services/2001/ops", {"op": "power_off"}, idem="demo-0001")
op = r["data"]["operation"]
print(status, "任务 %s 状态 %s" % (op["op_id"], op["state_label"]))

# 6) 轮询到终态
for _ in range(10):
    time.sleep(2)
    t = call("/v1/operations/" + op["op_id"])[1]["data"]["operation"]
    if t["state"] in ("done", "failed"):
        print("%s / 机器状态 %s / 到位 %s" % (
            t["state_label"], t["upstream_state"], t["settled"]))
        break

# 7) 能力清单:哪些操作对下游开放
for op in call("/v1/operations/catalog")[1]["data"]["operations"]:
    print("%-14s %-8s 开放=%s 有风险=%s" % (
        op["operation"], op["label"], op["allowed"], op["dangerous"]))

Node.js

#

需要 Node 18 以上(用内置的 fetch)。

const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';

async function call(path, payload, idem) {
  const init = { headers: { Authorization: 'Bearer ' + KEY } };
  if (payload) {
    init.method = 'POST';
    init.headers['Content-Type'] = 'application/json';
    if (idem) init.headers['Idempotency-Key'] = idem;
    init.body = JSON.stringify(payload);
  }
  const res = await fetch(API + path, init);
  const body = await res.json();          // 4xx / 5xx 也是 JSON 信封
  if (!body.ok) {
    // 认 code,不要认 message
    throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
  }
  return { status: res.status, body };
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 1) 商品列表
const list = (await call('/v1/products')).body.data;
for (const p of list.products) {
  for (const pr of p.prices) {
    if (!pr.priced) continue;
    console.log(`${p.name} ${pr.cycle_label} 收 ${pr.price}` +
                `(折前 ${pr.list_price} 减免 ${pr.discount_percent}%)`);
  }
}

// 2) 服务清单
const svc = (await call('/v1/services')).body.data;
console.log(`云服务 ${svc.count} 台,另有独立产品 ${svc.manual_count} 台`);

// 3) 一台服务
const one = (await call('/v1/services/2001')).body.data.service;
console.log(`${one.name} 状态 ${one.status_label} 到期 ${one.next_due_date}`);

// 4) 余额(金额是字符串,别用浮点累加)
const bal = (await call('/v1/balance')).body.data.balance;
console.log(`余额 ${bal.amount} ${bal.currency}`);

// 5) 关机:202 = 已受理,去轮询任务
const accepted = await call('/v1/services/2001/ops', { op: 'power_off' }, 'demo-0001');
const op = accepted.body.data.operation;
console.log(accepted.status, `任务 ${op.op_id} 状态 ${op.state_label}`);

// 6) 轮询到终态
for (let i = 0; i < 10; i++) {
  await sleep(2000);
  const t = (await call('/v1/operations/' + op.op_id)).body.data.operation;
  if (t.state === 'done' || t.state === 'failed') {
    console.log(`${t.state_label} / 机器状态 ${t.upstream_state} / 到位 ${t.settled}`);
    break;
  }
}

// 7) 开通:下订单并从账户余额扣款(余额不足会直接拒,什么都别想发生)
const made = await call('/v1/services/2600/ops',
                        { op: 'order', product_id: 11, cycle: 'monthly' }, 'demo-0002');
console.log('订单', made.body.data.result.order.id,
            '未付账单', made.body.data.result.invoice.amount);

十五、常见问题(排障)

问:第一次调就返 503,error.code 是 api_disabled。是我的密钥没生效吗?

不是。这是本站整体把接口关着(默认就是关的)。密钥本身没问题。
去问本站管理员什么时候开放;开放之后同一把密钥直接能用,不用重新申请。
程序上请把 503 当成"稍后重试并且退避"。

问:503 加 not_enabled 又是什么?

本站的密钥功能还没建起来(数据库迁移还没执行)。
这和你的密钥、你的调用方式都无关,找本站管理员。

问:503 加 operation_store_unavailable 呢?

写操作那一族的功能还没建起来。只读接口不受影响,照常能调。

问:401 加 invalid_api_key。是我密钥打错了吗?

invalid_api_key 一个 code 覆盖好几种原因:密钥不存在、密钥已经被吊销
或者轮换过、密钥格式不对(比如复制时少了字符)、密钥所属账号已被停用。
本站故意不告诉你到底是哪一种,否则等于给扫号的人反馈。

自检顺序:

  1. 密钥是不是 44 个字符、idc_ 开头、中间没有空格或换行?
  2. 是不是在会员中心点过"重新生成"(轮换)?轮换之后旧密钥立刻失效,

要用新的那一把。

  1. 账号本身是不是正常状态?
  2. 都不对,带 request_id 找本站管理员。

问:我读一台服务,返回 404,但那台服务明明存在。

如果它不是你这个账号的,本站就返回 404,而不是 403。
403 等于承认"这个编号存在,只是不是你的",那样别人就能拿编号从 1 数上去,
把本站有多少台机器、某个编号属于谁都摸出来。
所以"不存在"和"不是你的"必须是同一个应答。这不是 bug。
写操作也是同一条口径。

问:我调关机,返回 202,是不是已经关好了?

不是。202 只说明"我收下了,还没做完"。请拿应答里的 poll 去轮询,
看 state 和 settled。state 变成 done 也只说明机房受理了,
settled 才是"机器到没到目标状态"。刚下发时 settled 常常是 false,
过几秒再查一次。

问:409 operation_busy 是什么意思?

同一台服务的同一个操作正在处理中。响应里 error.detail.op_id 就是
在途那一笔的编号,去轮询它,不要重复提交。

问:我重复提交了两次,会不会开两次机?

不会。带了同一个 Idempotency-Key 就是回上一次那一笔(dispatched 是
false);没带键时,同一台服务同一操作在途会被 409 挡住,
60 秒内刚做成过会回 200 加"本次没有重复下发"。

问:429 了,等多久?

看响应头 Retry-After(单位是秒,当前实现是 60)。等够再试,
不要在这个窗口里一直重试。也请检查是不是把限流额度写死在代码里了。
注意读和写是两组独立的额度:你把读的额度打满,写照样能调。

问:为什么金额是字符串 "85.00" 而不是数字?

这是故意的,浮点数算钱会掉精度。请按定点小数处理,
不要转成浮点去累加。

问:下订单/续费会不会扣我账号的余额?

会。 这两个操作都会从账户余额扣款,走的是客户在账单页点
「用余额支付」的同一个函数,所以账目和客户自己点一下完全一致。

余额不够时当场拒绝,而且什么都没发生:不会下单、不会开单、不会扣款。
⚠️ 这个"拒绝"可能是一个 402 加 error.code = insufficient_balance,
也可能是一条 200 + operation.state = failed、outcome 以
upstream_failed|账户余额不足:… 开头的任务(见第六节)。
两种都要认,别只认 HTTP 状态码。

如果钱扣了但机器没开出来(机房拒绝、超时、上游余额不够),
那一刻的答案是明确的:钱不退、账单保持已付,
任务判 failed 并在 outcome 最前面给出 provision_failed /
renew_failed,result.needs_operator 是 true。
订单和服务记录都留着,由本站人工处理。这和"客户自己支付时撞上同样情况"
的处置一模一样。请把 op_id / request_id 提供给我们,
不要重复提交。

问:接口会不会突然少一个字段?

不会。字段是稳定契约,error.code 也是。
新增字段是可能的,所以请让你的解析器忽略不认识的字段,
不要因为多了一个字段就报错。

问:query_count 是什么?

本站内部用的诊断计数,表示这一次请求执行了多少条数据库语句。
它随时可能变化,不要依赖它做任何判断。

问:响应会被缓存吗?

不会。本站给每个应答都加了 Cache-Control: no-store 和
Pragma: no-cache。你自己的程序也请不要长期缓存余额。

问:时间是什么时区?

日期一律是 Y-m-d 字符串,例如 "2026-11-09",时间戳字段是
Y-m-d H:i:s,例如 "2026-10-10 14:20:31"。
原样返回本站数据库里的值,没有做任何时区换算,也不会给你 Unix 时间戳。
days_to_due 是拿本站服务器当天算出来的天数,已经过期就是负数。
所以不要假设它是某个固定时区。

十六、这个版本明确不提供的东西

  • 看不到别人的任何数据。密钥只属于一个账号,只读和写都一样。
  • 不返回任何密码。服务器 root 密码、控制面板密码都不返回。

应答里只有 has_password(有没有设过密码)和 login_block
(连接信息那几行,不含密码)。要密码请登录会员中心,
到「服务详情 → 连接信息」看。

  • 不含独立产品。/v1/services 只列云产品;独立产品(人工开通那种)

的台数会在 manual_count 里给你一个数字和一句说明,清单请在会员中心看。
(独立产品可以下单和续费,但不能开关机 —— 它没有机房机器可操作。)

  • 不能删除机器、不能重装系统、不能重置密码、不能强制关机、

不能暂停 / 解除暂停、不能锁定 / 解锁。这些操作要么不可逆、
要么会改掉客户手上的凭据、要么是我们催费和封停的手段。
它们不在操作表里,路由都匹配不上,调了就是 400 或者 404。

  • VNC 控制台不对下游开放(见第十二节末尾)。
  • 不接受指定配置报价以外的算价。价格恒按你给的 selection(或默认配置)算,

由本站同一段代码算出来,接口不自己算价。

  • 不返回成本价。可配置项只报加价 price_diff,不报成本。
  • 续费不支持换周期。换周期要拿定价重算一笔价,那是第三处算价,

本版本不做;要换周期请到会员中心或联系客服。

十七、创建密钥和查看本账号接口清单

这些需要登录本站会员中心:创建、轮换、吊销、重命名 API 密钥,
以及查看本账号的「API 状态」和「API 接口地址」。

位置是:会员中心 → 代理分销 → API 管理,地址 /profile-api.php。
本账号的接口清单摘要也印在那一页上。

十八、本站自有系统

本站自己的跨站同步、监控、财务、客服工具使用另一套凭据,
不对下游开放,也不属于这份文档的范围。
需要对接本站自有系统,请联系本站管理员。

联系我们

电话028-64007100

手机上或没装 QQ 客户端时点不开,可以在本站提交工单联系我们。