> ## 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>

애플리케이션이 Discord 안에서 사용자의 [Vito](/ko/members/vito) 잔액을 다룰 수 있게 해주는 REST API입니다. 잔액을 읽고, 청구하고, 지급하고, 사용자 간에 옮길 수 있습니다. 모든 엔드포인트는 JSON을 반환하며 `/v1` 아래에서 버전이 관리됩니다.

<Warning>
  **Vito는 실제 돈으로 바뀌거나 실제 돈에서 오지 않습니다.** 오직 Vetox 잔액 사이에서만 이동합니다.
</Warning>

## 접근 권한 얻기

접근 권한은 **프로젝트 단위**로 부여됩니다. 네 가지가 모두 필요합니다.

<Steps>
  <Step title="활성 상태의 Silver 이상 멤버십">
    승인 시점뿐 아니라 호출할 때마다 확인합니다.
  </Step>

  <Step title="승인된 개발자 신청서">
    대시보드의 Vito API 페이지에서 제출합니다. Vetox 팀이 직접 검토합니다.
  </Step>

  <Step title="동의한 API 개발자 약관">
    신청서를 제출할 때 함께 동의합니다.
  </Step>

  <Step title="프로젝트에 필요한 스코프">
    작성하신 설명을 바탕으로 Vetox 팀이 부여합니다.
  </Step>
</Steps>

<Tip>
  무엇을 만들고 있는지, 키를 어떻게 보관할지 구체적으로 적어 주세요. 거절되는 것은 늘 모호한 신청서입니다.
</Tip>

### 스코프

| 스코프                 | 허용 범위                    |
| ------------------- | ------------------------ |
| `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>
  **키와 서명 시크릿은 정확히 한 번만 표시됩니다.** 승인 후 API 키 탭에서 이를 확인할 수 있는 **7일의 기간**이 주어집니다. 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분** 동안 유효합니다. 사용자에게 DM도 발송됩니다.
  </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? }` — 확인 페이지와 DM에 표시됩니다 |
| `imageUrl`    | 아니요   | 반드시 `https://` 여야 합니다                                         |
| `metadata`    | 아니요   | 문자열 키/값 쌍 최대 **10개**, 그대로 전달됩니다                               |

## 사용자에게 지급하기

`POST /v1/add`는 **내 잔액을 재원으로** 사용자에게 Vito를 지급합니다. 보상이나 환불에 사용합니다. `guildId`와 `product`를 제외하면 `/deduct`와 같은 필드를 씁니다.

<Note>
  청구와 달리 지급에는 **확인 단계가 없습니다** — 즉시 정산됩니다. `credit:create` 스코프와 충분한 잔액이 필요하며, 그렇지 않으면 호출이 \*\*402 `VITO_INSUFFICIENT_OWNER_FUNDS`\*\*를 반환합니다.
</Note>

## 수수료

모든 청구는 플랫폼 수수료를 뺀 금액으로 정산됩니다. 앱 내 Vito 송금과 동일한 요율이며 **본인의** 멤버십 등급을 기준으로 합니다.

| 내 멤버십                | 수수료 |
| -------------------- | --- |
| Normal, Silver, Gold | 7%  |
| Platinum             | 6%  |
| Diamond              | 5%  |

<Note>
  **5 Vito 이하 금액은 수수료가 없으며**, `/v1/add`를 통한 지급은 항상 수수료가 없습니다.
</Note>

## Webhook

설정 탭에서 `https` 콜백 URL을 하나 이상 추가하세요. 확인 요청이 최종 상태에 도달할 때마다 Vito가 서명된 `POST`를 보냅니다.

<Warning>
  webhook은 프로젝트에 콜백 URL **과** 서명 시크릿이 **모두** 있을 때만 발송됩니다. 시크릿(`whsec_…`)은 API 키 탭에서 한 번만 확인할 수 있습니다.
</Warning>

### 서명 검증

모든 전송에는 `X-Vito-Signature` 헤더가 포함됩니다.

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

각 전송에는 두 개의 헤더가 더 따라옵니다. 재시도 시 같은 id가 다시 오므로 `X-Vito-Event-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 | 해당 엔드포인트에 필요한 스코프가 키에 없습니다         |
| `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 |

분당 할당량의 절반(최소 30)에 해당하는 IP별 한도도 함께 적용됩니다.

<Warning>
  부하가 높을 때 **쓰기 엔드포인트는 실패 시 차단됩니다** — 이중 지불 위험을 감수하는 대신 청구를 거부합니다. 읽기 엔드포인트는 실패 시 허용됩니다. 거부된 쓰기는 "일어나지 않은 것"으로 간주하고 다시 시도하세요.
</Warning>

<Note>
  재시도를 안전하게 중복 제거하려면 `Idempotency-Key` 헤더를 보내세요.
</Note>

## 엔드포인트

| 엔드포인트                           | 스코프                 |
| ------------------------------- | ------------------- |
| `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">
    실제로 사용하는 스코프만 요청하고 IP 허용 목록을 켜 두세요.
  </Accordion>
</AccordionGroup>

## 문제 해결

<AccordionGroup>
  <Accordion title="모든 호출이 인증 실패를 반환합니다">
    소유자의 멤버십이 만료되었습니다. 호출할 때마다 다시 확인합니다.
  </Accordion>

  <Accordion title="키를 잃어버렸습니다">
    복구할 수 없습니다 — 해시만 저장되기 때문입니다. 교체해서 새로 받으세요.
  </Accordion>

  <Accordion title="교체 후 webhook 서명 검증이 실패합니다">
    24시간의 중첩 기간 동안에는 두 시크릿을 모두 받아들이세요.
  </Accordion>

  <Accordion title="청구가 끝내 완료되지 않습니다">
    사용자가 승인하지 않았습니다. 확인 요청은 10분 후 만료됩니다.
  </Accordion>

  <Accordion title="webhook이 전혀 오지 않습니다">
    프로젝트에는 콜백 URL과 서명 시크릿이 모두 필요합니다. 둘 중 하나만으로는 아무것도 전송되지 않습니다.
  </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="/ko/members/vito">
    잔액, PIN, 수수료.
  </Card>

  <Card title="결제 요청" icon="receipt" href="/ko/account/payment-requests">
    청구했을 때 사용자에게 보이는 화면.
  </Card>
</CardGroup>
