# 인증 및 요청 서명

> project UUID와 API key를 사용해 HMAC-SHA256으로 API 요청에 서명합니다.

모든 API 요청(수신 webhook 제외)에는 project UUID와 요청 서명이 포함되어야 합니다. 서명은 요청이 본인에게서 비롯된 것이며 전송 도중 변경되지 않았음을 증명합니다.

## API key

2328.io는 동일한 서명 알고리즘을 공유하지만 서로 다른 endpoint를 담당하는 **두 개의 키**를 사용합니다:

| Key | 용도 |
|-----|----------|
| **API key** | 결제, 정적 지갑, 잔액, 환율, 그리고 결제 / 정적 지갑 webhook 검증 |
| **Payout API key** | 모든 `/v1/payout/*` endpoint 및 출금 webhook 검증 |

두 키 모두 [2328.io](https://2328.io)의 프로젝트 설정에 보관됩니다. 아래 예시에서는 일반적으로 "API key"라고 표기하지만, 호출하는 endpoint에 맞는 키로 대체해서 사용해야 합니다.

> **INFO:** **절대로** 두 키를 섞어 쓰지 마세요: 일반 API key로 출금 요청에 서명하거나(또는 Payout key로 결제 요청에 서명하면) 서명 오류가 반환됩니다.

## 필수 header

| Header | 타입 | 필수 | 설명 |
|--------|------|----------|-------------|
| `Content-Type` | string | yes | 항상 `application/json` |
| `project` | string | yes | 프로젝트 UUID |
| `sign` | string | yes | API key로 계산한 요청의 HMAC-SHA256 서명 |
| `User-Agent` | string | 예 | 애플리케이션을 식별합니다 (예: `MyShop/1.4 (+https://myshop.example)`). `User-Agent`가 없는 요청은 차단될 수 있습니다. |

## 서명 동작 방식

서명을 요청 본문의 지문이라고 생각하면 됩니다. 다음 절차로 생성합니다:

1. 본문을 JSON으로 직렬화합니다(공백 없이 압축된 형식).
2. 그 JSON을 base64로 인코딩합니다. 이 단계는 언어 간 입력을 정규화합니다 — 일단 평문 ASCII가 되면 모든 언어가 HMAC에 동일한 바이트를 입력합니다.
3. base64 문자열에 대해 API key로 **HMAC-SHA256**을 계산한 뒤 결과를 소문자 hex로 변환합니다.

본문이 없는 **GET** 등의 요청 유형에서는 JSON 대신 빈 문자열에 서명합니다.

> **INFO:** 빈 문자열의 서명은 주어진 API key에 대해 일정합니다. GET 호출이 많다면 캐싱해도 됩니다.

## 구현 예시

<CodeSnippet name="apiSign" langs="php,js,ts,python,go" />

### 본문이 없는 요청 (GET)

본문이 비어있는 요청(예: `GET /v1/payout/status/{uuid}`)에서는 빈 문자열에 서명합니다:

<CodeSnippet name="apiSignBodyless" langs="curl,php,js,ts,python,go" />

## 전체 요청 예시

<CodeSnippet name="fullRequestExample" langs="curl,php,js,ts,python,go" />

> **DANGER:** **API key를 클라이언트 측 코드에 절대 노출하지 마세요.** 요청 서명은 백엔드에서 수행해야 합니다. 유출된 API key는 누구에게나 가맹점 계정에 대한 전체 접근 권한을 부여합니다.

## Webhook 서명 검증

2328.io가 webhook을 보낼 때는 동일한 알고리즘이 역방향으로 실행됩니다:

1. payload에서 `sign` 필드를 추출합니다.
2. 나머지 필드를 JSON으로 인코딩합니다(공백 없이 압축).
3. 그 문자열을 base64로 인코딩합니다.
4. 적절한 키로 `HMAC-SHA256`을 계산합니다.
5. 수신한 `sign`과 계산 결과를 **상수 시간 비교** 함수로 비교합니다(`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`, `subtle.ConstantTimeCompare`, `OpenSSL.fixed_length_secure_compare`).

서명 키는 webhook의 출처에 따라 달라집니다:

| Webhook | 검증에 사용할 키 |
|---------|---------------------|
| 결제 / 정적 지갑 webhook (`/v1/payment`, `/v1/static-wallet`) | **API key** |
| 출금 webhook (`/v1/payout`) | **Payout API key** |

> **WARNING:** **검증의 일반적인 함정.** 사용하시는 JSON 인코더는 발신자가 생성한 것과 **완전히 동일한 바이트**를 생성해야 합니다 — 그렇지 않으면 Base64가 달라져 서명이 일치하지 않습니다.

- **Go**: `json.NewEncoder`와 `SetEscapeHTML(false)`를 사용하세요. 기본 `json.Marshal`은 `<`, `>`, `&`를 `<`로 이스케이프하여 서명을 망가뜨립니다.
- **Python**: `json.dumps`에 `ensure_ascii=False`를 전달하세요. 이를 사용하지 않으면 비 ASCII(키릴 문자, 한자 등)가 `\uXXXX`로 이스케이프됩니다.
- **압축 JSON**: 필드 사이에 공백 없음 (Python에서는 `separators=(",", ":")`).
- **필드 순서** (Go): 일반 `map[string]any`는 재인코딩 시 키 순서를 무작위로 바꿉니다. `json.RawMessage`, 순서가 정해진 struct를 사용하거나, 원시 바이트에서 `sign`을 제거하세요.

검증이 계속 실패한다면, payload에 대해 직접 `apiSign`을 실행해보세요 — 수신한 `sign`과 동일한 16진수 문자열을 생성해야 합니다.

> **INFO:** **유효한 서명은 재전송(replay)을 막지 못합니다.** 서명은 webhook이 2328.io에서 왔음을 증명할 뿐 — 공격자가 *캡처한* webhook을 나중에 다시 전송하는 것을 막지 못합니다. 자금을 적립하기 전에 항상 `uuid`(정적 지갑의 경우 `txid`)로 멱등성을 확인하세요. 서명이 없거나 잘못된 경우 HTTP `401`로 거절하세요.

전체 코드 예시는 **[Webhook Notifications](/docs/webhooks#verifying-the-signature)**에 있습니다. 재시도 처리와 멱등성 규칙은 [모범 사례](/docs/webhooks#best-practices)를 참조하세요.