인증 및 요청 서명
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의 프로젝트 설정에 보관됩니다. 아래 예시에서는 일반적으로 "API key"라고 표기하지만, 호출하는 endpoint에 맞는 키로 대체해서 사용해야 합니다.
절대로 두 키를 섞어 쓰지 마세요: 일반 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가 없는 요청은 차단될 수 있습니다. |
서명 동작 방식
서명을 요청 본문의 지문이라고 생각하면 됩니다. 다음 절차로 생성합니다:
- 본문을 JSON으로 직렬화합니다(공백 없이 압축된 형식).
- 그 JSON을 base64로 인코딩합니다. 이 단계는 언어 간 입력을 정규화합니다 — 일단 평문 ASCII가 되면 모든 언어가 HMAC에 동일한 바이트를 입력합니다.
- base64 문자열에 대해 API key로 HMAC-SHA256을 계산한 뒤 결과를 소문자 hex로 변환합니다.
본문이 없는 GET 등의 요청 유형에서는 JSON 대신 빈 문자열에 서명합니다.
빈 문자열의 서명은 주어진 API key에 대해 일정합니다. GET 호출이 많다면 캐싱해도 됩니다.
구현 예시
<?php
function apiSign(array $data, string $apiKey): string {
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$base64 = base64_encode($json);
return hash_hmac('sha256', $base64, $apiKey);
}import crypto from "crypto";
export function apiSign(data, apiKey) {
const json = JSON.stringify(data);
const base64 = Buffer.from(json).toString("base64");
return crypto.createHmac("sha256", apiKey).update(base64).digest("hex");
}import { createHmac } from "crypto";
export function apiSign(data: object, apiKey: string): string {
const json = JSON.stringify(data);
const base64 = Buffer.from(json).toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}import json
import hmac
import hashlib
import base64
def api_sign(data: dict, api_key: str) -> str:
# ensure_ascii=False keeps non-ASCII characters (Cyrillic, Chinese, …)
# as-is. Without it, Python escapes them to \uXXXX and the signature
# diverges from PHP / Node / Go.
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
b64 = base64.b64encode(body.encode("utf-8")).decode()
return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()package sign
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
)
func ApiSign(data any, apiKey string) (string, error) {
// json.Encoder with SetEscapeHTML(false) — without it, Go escapes <, >, &
// to \u003c etc., which breaks compatibility with PHP / Node / Python.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(data); err != nil {
return "", err
}
// Encoder appends a trailing newline — drop it.
body := bytes.TrimRight(buf.Bytes(), "\n")
b64 := base64.StdEncoding.EncodeToString(body)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil)), nil
}본문이 없는 요청 (GET)
본문이 비어있는 요청(예: GET /v1/payout/status/{uuid})에서는 빈 문자열에 서명합니다:
SIGN=$(printf '' | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $NF}')$sign = hash_hmac('sha256', base64_encode(''), $apiKey);import { createHmac } from "crypto";
const sign = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");import { createHmac } from "crypto";
const sign: string = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");import hmac
import hashlib
import base64
sign = hmac.new(
api_key.encode(),
base64.b64encode(b"").decode().encode(),
hashlib.sha256,
).hexdigest()package sign
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func EmptyBodySign(apiKey string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(""))
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil))
}전체 요청 예시
curl -X POST https://api.2328.io/api/v1/payment \
-H "Content-Type: application/json" \
-H "User-Agent: MyShop/1.0 (+https://myshop.example)" \
-H "project: YOUR_PROJECT_UUID" \
-H "sign: YOUR_HMAC_SIGNATURE" \
-d '{"amount":"100.00","currency":"USD","order_id":"ORDER-123"}'<?php
function apiSign(string $body, string $apiKey): string {
return hash_hmac('sha256', base64_encode($body), $apiKey);
}
$project = 'YOUR_PROJECT_UUID';
$apiKey = 'YOUR_API_KEY';
$data = [
'amount' => '100.00',
'currency' => 'USD',
'order_id' => 'ORDER-123',
];
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sign = apiSign($body, $apiKey);
$ch = curl_init('https://api.2328.io/api/v1/payment');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'User-Agent: MyShop/1.0 (+https://myshop.example)',
"project: $project",
"sign: $sign",
],
]);
$response = json_decode(curl_exec($ch), true);import { createHmac } from "crypto";
function apiSign(body, apiKey) {
const base64 = Buffer.from(body, "utf8").toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}
const data = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID,
sign,
},
body,
});
const json = await res.json();import { createHmac } from "crypto";
function apiSign(body: string, apiKey: string): string {
const base64 = Buffer.from(body, "utf8").toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}
type CreatePaymentBody = {
amount: string;
currency: string;
order_id: string;
};
type CreatePaymentResponse = { state: number; result: unknown };
const data: CreatePaymentBody = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY!);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID!,
sign,
},
body,
});
const json = (await res.json()) as CreatePaymentResponse;import json
import hmac
import hashlib
import base64
import httpx
def api_sign(body: str, api_key: str) -> str:
b64 = base64.b64encode(body.encode("utf-8")).decode()
return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
data = {
"amount": "100.00",
"currency": "USD",
"order_id": "ORDER-123",
}
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
sign = api_sign(body, API_KEY)
r = httpx.post(
"https://api.2328.io/api/v1/payment",
headers={
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
"project": PROJECT_UUID,
"sign": sign,
},
content=body.encode("utf-8"),
)
response = r.json()package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
)
func ApiSign(body []byte, apiKey string) string {
b64 := base64.StdEncoding.EncodeToString(body)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil))
}
func marshalCanonical(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func main() {
data := struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
OrderID string `json:"order_id"`
}{
Amount: "100.00",
Currency: "USD",
OrderID: "ORDER-123",
}
body, err := marshalCanonical(data)
if err != nil {
panic(err)
}
sign := ApiSign(body, apiKey)
req, _ := http.NewRequest("POST",
"https://api.2328.io/api/v1/payment",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "MyShop/1.0 (+https://myshop.example)")
req.Header.Set("project", projectUUID)
req.Header.Set("sign", sign)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
}API key를 클라이언트 측 코드에 절대 노출하지 마세요. 요청 서명은 백엔드에서 수행해야 합니다. 유출된 API key는 누구에게나 가맹점 계정에 대한 전체 접근 권한을 부여합니다.
Webhook 서명 검증
2328.io가 webhook을 보낼 때는 동일한 알고리즘이 역방향으로 실행됩니다:
- payload에서
sign필드를 추출합니다. - 나머지 필드를 JSON으로 인코딩합니다(공백 없이 압축).
- 그 문자열을 base64로 인코딩합니다.
- 적절한 키로
HMAC-SHA256을 계산합니다. - 수신한
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 |
검증의 일반적인 함정. 사용하시는 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진수 문자열을 생성해야 합니다.
유효한 서명은 재전송(replay)을 막지 못합니다. 서명은 webhook이 2328.io에서 왔음을 증명할 뿐 — 공격자가 캡처한 webhook을 나중에 다시 전송하는 것을 막지 못합니다. 자금을 적립하기 전에 항상 uuid(정적 지갑의 경우 txid)로 멱등성을 확인하세요. 서명이 없거나 잘못된 경우 HTTP 401로 거절하세요.
전체 코드 예시는 **Webhook Notifications**에 있습니다. 재시도 처리와 멱등성 규칙은 모범 사례를 참조하세요.