> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vetox.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Vito API 与购买

> 让经过批准的应用在 PIN 确认下扣费您的 Vito，并从购买页面管理付款请求。

<Info>
  需要 **Silver** 或更高等级的会员 — 申请时需要，之后每一次已认证的调用同样需要。若密钥所有者的会员到期，项目会被冻结，直到其重新订阅。
</Info>

一套 REST API，让你的应用在 Discord 内直接操作用户的 [Vito](/zh-CN/members/vito) 余额：读取、扣款、充值，或在用户之间转移。所有端点都返回 JSON，并在 `/v1` 下进行版本管理。

<Warning>
  **Vito 永远不会与真实货币互相兑换。** 它只在 Vetox 余额之间流动。
</Warning>

## 获取访问权限

访问权限**按项目**授予。以下四项缺一不可：

<Steps>
  <Step title="有效的 Silver 或更高等级会员">
    每次调用都会检查，而不只是在审批时检查。
  </Step>

  <Step title="已获批准的开发者申请">
    在控制面板的 Vito API 页面提交，由 Vetox 团队人工审核。
  </Step>

  <Step title="已接受的 API 开发者条款">
    在提交申请时确认。
  </Step>

  <Step title="项目所需的 scope">
    由 Vetox 团队根据你的描述授予。
  </Step>
</Steps>

<Tip>
  请具体说明你要做什么、以及会如何保存密钥。被拒绝的往往正是描述含糊的申请。
</Tip>

### Scope

| Scope               | 允许                |
| ------------------- | ----------------- |
| `balance:read`      | 读取用户的 Vito 余额     |
| `deduct:create`     | 从用户余额中扣款          |
| `credit:create`     | 用你自己的余额为用户充值 Vito |
| `transfer:create`   | 在两个用户之间转移 Vito    |
| `transactions:read` | 列出并读取你项目的交易       |

## 身份验证

将密钥作为 Bearer 令牌发送：

```bash theme={null}
Authorization: Bearer vito_live_xxxxxxxxxxxxxxxx
```

两个可选层级可进一步加固项目：

* **IP 白名单** — 将调用限制在特定的服务器 IP
* **速率限制** — 按项目设定的上限，随所有者的会员等级提升

### 密钥、轮换与保管

<Warning>
  **密钥和签名密钥只会显示一次。** 获批后你有 **7 天的窗口期**可在 API 密钥标签页中查看它们。Vetox 只保存哈希，无法再次显示 — 错过窗口期就只能轮换以获取新的。
</Warning>

* 只将其保存在**服务端** — 任何持有它的人都能向你的用户扣款
* 在 API 密钥标签页中轮换。旧密钥会继续生效 **24 小时**作为宽限期，让你不停机完成部署
* webhook 签名密钥单独轮换，拥有各自的 24 小时重叠期
* 一旦泄露，请立即轮换

## 向用户扣款

<Warning>
  **仅凭密钥无法转移用户的 Vito。** 每一笔扣款都需要用户在 `vetox.io` 上用钱包 PIN 批准 — 绝不会在你的应用内，也不会在 Discord 内进行。
</Warning>

<Steps>
  <Step title="你的应用调用 POST /v1/deduct">
    附带用户、金额、发起的 `guildId` 以及商品详情。
  </Step>

  <Step title="Vito 返回 confirmUrl">
    一条待确认记录，有效期 **10 分钟**。用户同时会收到私信。
  </Step>

  <Step title="用户用 PIN 批准">
    在 `vetox.io` 上完成。
  </Step>

  <Step title="Vito 结算并通知">
    余额被扣除，交易被记录；若你配置了 webhook，会发送一条带签名的通知。
  </Step>

  <Step title="你的应用验证并完成">
    校验签名，然后解锁内容或发放商品。
  </Step>
</Steps>

<Warning>
  **只在收到 `confirmation.completed` 时才完成你的操作** — 绝不要依据 `/deduct` 的响应。在那一刻扣款尚未最终确定。
</Warning>

### 请求参数 — `/v1/deduct`

| 字段            | 必填    | 说明                                                      |
| ------------- | ----- | ------------------------------------------------------- |
| `discordId`   | **是** | 用户的 Discord ID — 17 到 20 位的 snowflake                   |
| `amount`      | **是** | 正整数                                                     |
| `guildId`     | **是** | 扣款发起所在的 Discord 服务器。每一笔扣款都必须来自某个服务器                     |
| `reason`      | 否     | 最多 256 个字符。会展示给用户，并在 webhook 中回传                        |
| `merchantRef` | 否     | 你自己的参考号，最多 128 个字符。用于将 webhook 与你的记录对应起来                |
| `product`     | 否     | `{ type, name, description?, imageUrl? }` — 显示在确认页面和私信中 |
| `imageUrl`    | 否     | 必须是 `https://`                                          |
| `metadata`    | 否     | 最多 **10** 组字符串键值对，原样透传                                  |

## 为用户充值

`POST /v1/add` **从你自己的余额**为用户充值 Vito，用于奖励或退款。除 `guildId` 和 `product` 外，字段与 `/deduct` 相同。

<Note>
  与扣款不同，充值**没有确认步骤** — 会立即结算。需要 `credit:create` scope 和足够余额，否则调用返回 **402 `VITO_INSUFFICIENT_OWNER_FUNDS`**。
</Note>

## 费用

每笔扣款在扣除平台手续费后结算给你 — 采用与应用内 Vito 转账相同的费率，依据**你的**会员等级：

| 你的会员等级             | 费率 |
| ------------------ | -- |
| Normal、Silver、Gold | 7% |
| Platinum           | 6% |
| Diamond            | 5% |

<Note>
  **5 Vito 及以下的金额免手续费**，通过 `/v1/add` 的充值则始终免手续费。
</Note>

## Webhook

在设置标签页中添加一个或多个 `https` 回调地址。每当一条确认记录进入终态，Vito 就会发送一条带签名的 `POST`。

<Warning>
  只有当项目**同时**具备回调地址**和**签名密钥时，webhook 才会发送。请在 API 密钥标签页中一次性查看密钥（`whsec_…`）。
</Warning>

### 校验签名

每次投递都带有 `X-Vito-Signature` 请求头：

```text theme={null}
X-Vito-Signature: ts=<unix>;h1=<hex>
```

每次投递还会附带另外两个请求头 — 请用 `X-Vito-Event-Id` 作为去重键，因为重试会发送相同的 id：

| 请求头                 | 内容                          |
| ------------------- | --------------------------- |
| `X-Vito-Event-Id`   | 该事件的固定 id — 多次重试保持一致        |
| `X-Vito-Event-Type` | 例如 `confirmation.completed` |

<Warning>
  **在签名密钥轮换期间，该请求头会携带不止一个签名**，最新的在前：

  ```text theme={null}
  X-Vito-Signature: ts=<unix>;h1=<新>;h1=<旧>
  ```

  只要**任意一个** `h1` 匹配就应接受这次投递。只读取第一个的校验代码会拒绝所有 webhook，直到它部署了新密钥 — 而这恰恰让 24 小时重叠期失去了意义。
</Warning>

用你的签名密钥对 `<ts>:<rawBody>` 重新计算 HMAC，并以常数时间比较。

```js theme={null}
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const parts = header.split(';');
  const ts = parts.find(p => p.startsWith('ts='))?.slice(3);
  const sigs = parts.filter(p => p.startsWith('h1=')).map(p => p.slice(3));
  if (!ts || sigs.length === 0) return false;

  // Reject anything older than ~5 minutes — replay protection.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${ts}:${rawBody}`).digest('hex'),
  );

  // Any matching signature is valid — a rotation emits several.
  return sigs.some(sig => {
    const actual = Buffer.from(sig);
    // timingSafeEqual throws when the lengths differ, so check first.
    return (
      actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
    );
  });
}
```

<Warning>
  请针对**原始请求体**进行校验，要在任何 JSON 解析或中间件改写之前完成。
</Warning>

### 事件

共五种事件类型，全部共用同一种载荷结构。结果由 `data.status` 承载。

| 事件                       | 含义                                         |
| ------------------------ | ------------------------------------------ |
| `confirmation.completed` | 已批准并完成扣款。携带 `transactionId`。**请在此处完成你的操作** |
| `confirmation.failed`    | 无法完成 — 余额不足或内部错误。携带 `failureReason`        |
| `confirmation.expired`   | 未在 10 分钟内确认。没有 Vito 发生转移                   |
| `confirmation.cancelled` | 用户取消了。没有 Vito 发生转移                         |
| `credit.completed`       | 一笔由所有者出资的 `/add` 已结算。立即触发 — 没有确认步骤         |

<Note>
  webhook 会以退避策略**重试 5 次**。请尽快返回 2xx，并异步执行你的发货逻辑。
</Note>

## 错误码

每个响应都包在一层信封里。成功携带 `data`，失败携带 `error`，两者绝不会同时出现：

```json theme={null}
{
  "success": false,
  "error": {
    "type": "Forbidden",
    "message": "API key lacks the required scope for this operation.",
    "code": "VITO_INSUFFICIENT_SCOPE"
  },
  "requestId": "…",
  "timestamp": 1735161600000
}
```

<Warning>
  **每个错误码都带有 `VITO_` 前缀。** 请以完整字符串进行判断 — 裸的 `RATE_LIMITED` 或 `FORBIDDEN` 绝不会出现在响应中。
</Warning>

| 错误码                                   | 状态  | 含义                           |
| ------------------------------------- | --- | ---------------------------- |
| `VITO_VALIDATION_ERROR`               | 400 | 某个字段缺失或格式有误                  |
| `VITO_INVALID_AMOUNT`                 | 400 | `amount` 不是正整数               |
| `VITO_AMOUNT_LIMIT_EXCEEDED`          | 400 | 超过你的单笔上限                     |
| `VITO_DAILY_VOLUME_EXCEEDED`          | 400 | 该调用会突破你的每日交易额上限              |
| `VITO_SELF_TRANSFER`                  | 400 | 转出方与接收方是同一个用户                |
| `VITO_INVALID_API_KEY`                | 401 | 密钥缺失、格式错误、已吊销或无法识别           |
| `VITO_INSUFFICIENT_FUNDS`             | 402 | 用户余额不足以支付该笔扣款                |
| `VITO_INSUFFICIENT_OWNER_FUNDS`       | 402 | **你的**余额不足以支撑一次 `/add`       |
| `VITO_INSUFFICIENT_SCOPE`             | 403 | 密钥缺少该端点所需的 scope             |
| `VITO_IP_NOT_ALLOWED`                 | 403 | 发起调用的 IP 不在白名单中              |
| `VITO_OWNER_MEMBERSHIP_INACTIVE`      | 403 | 所有者的会员已到期 — 冻结至其重新订阅         |
| `VITO_GUILD_NOT_ALLOWED`              | 403 | `guildId` 不在该项目的允许服务器列表中     |
| `VITO_TOS_NOT_ACCEPTED`               | 403 | 尚未接受开发者条款，或有更新版本待确认          |
| `VITO_PROJECT_FROZEN`                 | 403 | 已冻结 — 通常是所有者会员到期所致           |
| `VITO_PROJECT_SUSPENDED`              | 403 | 已被 Vetox 团队暂停                |
| `VITO_PROJECT_BANNED`                 | 403 | 已被 Vetox 团队封禁                |
| `VITO_USER_BLACKLISTED`               | 403 | 该用户被禁止进行 Vito 操作             |
| `VITO_ACCOUNT_LOCKED`                 | 403 | 用户钱包因多次 PIN 输入失败而被锁定         |
| `VITO_USER_NOT_FOUND`                 | 404 | 该 Discord ID 没有 Vito 账户      |
| `VITO_CONFIRMATION_NOT_FOUND`         | 404 | 无法识别的确认令牌                    |
| `VITO_IDEMPOTENCY_CONFLICT`           | 409 | 相同的 `Idempotency-Key`，但请求体不同 |
| `VITO_IDEMPOTENCY_IN_PROGRESS`        | 409 | 相同的请求仍在处理中 — 请稍后重试           |
| `VITO_CONFIRMATION_ALREADY_PROCESSED` | 409 | 该确认记录已经进入终态                  |
| `VITO_KEY_ROTATION_IN_PROGRESS`       | 409 | 已有一次轮换正在进行                   |
| `VITO_CONFIRMATION_EXPIRED`           | 410 | 10 分钟的有效期已过                  |
| `VITO_RATE_LIMITED`                   | 429 | 请放慢速度后重试                     |
| `VITO_INTERNAL_ERROR`                 | 500 | 我们这边出现了意外故障                  |

## 速率限制

| 所有者的会员等级           | 每分钟 | 每小时    |
| ------------------ | --- | ------ |
| 无                  | 60  | 1,000  |
| Silver 或 Gold      | 180 | 5,000  |
| Platinum 或 Diamond | 600 | 15,000 |

此外还有按 IP 的限制，为你每分钟配额的一半，最低不少于 30。

<Warning>
  在高负载下，**写入端点会「失败即拒绝」** — 宁可拒绝这笔扣款，也不冒重复扣款的风险；读取端点则「失败即放行」。请把被拒绝的写入视为「没有发生过」并重试。
</Warning>

<Note>
  发送 `Idempotency-Key` 请求头，即可安全地对重试去重。
</Note>

## 端点

| 端点                              | Scope               |
| ------------------------------- | ------------------- |
| `GET /v1/auth/verify`           | 任意                  |
| `POST /v1/auth/rotate-key`      | 任意                  |
| `GET /v1/balance/:discordId`    | `balance:read`      |
| `POST /v1/deduct`               | `deduct:create`     |
| `POST /v1/add`                  | `credit:create`     |
| `POST /v1/transfer`             | `transfer:create`   |
| `GET /v1/transactions` 和 `/:id` | `transactions:read` |
| `GET /v1/webhooks/events`       | `transactions:read` |

## 限制

* 确认记录会在 **10 分钟**后过期 — 请把未确认的请求当作已放弃
* `amount` 必须是正整数
* `metadata` 最多 10 个键
* 单笔与每日上限由 Vetox 团队设定，在设置标签页中以只读方式展示

## 安全检查清单

<AccordionGroup>
  <Accordion title="把密钥留在服务端" icon="lock">
    API 密钥与签名密钥绝不属于客户端代码。任何一个泄露都要立即轮换。
  </Accordion>

  <Accordion title="校验每一个 webhook" icon="signature">
    对照原始请求体校验签名，并拒绝早于约 5 分钟的投递。
  </Accordion>

  <Accordion title="只在 completed 时结算" icon="circle-check">
    切勿依据 `/deduct` 的响应发货 — 扣款要到 `confirmation.completed` 才算最终确定。
  </Accordion>

  <Accordion title="最小权限" icon="key">
    只申请你真正使用的 scope，并启用 IP 白名单。
  </Accordion>
</AccordionGroup>

## 疑难排查

<AccordionGroup>
  <Accordion title="每次调用都返回未授权">
    所有者的会员已到期。它在每次调用时都会重新检查。
  </Accordion>

  <Accordion title="我把密钥弄丢了">
    无法找回 — 系统只保存哈希。请轮换以获取新的密钥。
  </Accordion>

  <Accordion title="轮换后 webhook 签名校验失败">
    在 24 小时重叠期内请同时接受新旧两个密钥。
  </Accordion>

  <Accordion title="有一笔扣款始终没有完成">
    用户没有批准。确认记录会在 10 分钟后过期。
  </Accordion>

  <Accordion title="收不到任何 webhook">
    项目需要同时具备回调地址和签名密钥。只有其中之一时，什么都不会发送。
  </Accordion>

  <Accordion title="到账的 Vito 比我扣的少">
    这是结算手续费。使用 5 Vito 及以下的金额可以避免，或把它计入你的定价。
  </Accordion>

  <Accordion title="402 VITO_INSUFFICIENT_OWNER_FUNDS">
    `/v1/add` 由你自己的余额出资，并非凭空产生。请先充值。
  </Accordion>
</AccordionGroup>

<CardGroup cols={2}>
  <Card title="Vito" icon="coins" href="/zh-CN/members/vito">
    余额、PIN 与费用。
  </Card>

  <Card title="付款请求" icon="receipt" href="/zh-CN/account/payment-requests">
    你向用户扣款时，他们看到的界面。
  </Card>
</CardGroup>
