> For the complete documentation index, see [llms.txt](https://malgn.gitbook.io/cloud/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://malgn.gitbook.io/cloud/api/order-info-webhook.md).

# 주문 정보 웹훅

> **문서 정보**
>
> * **버전:** 1.0
> * **작성일:** 2026.06.05

### 1. 개요

웹훅을 등록하면 특정 이벤트 발생 시 지정된 URL로 HTTP POST 요청이 자동 전송됩니다. 개인정보(이름, 이메일 등)는 AES-128로 암호화되어 전송되며, HMAC-SHA256 서명으로 요청의 무결성을 검증할 수 있습니다.

### 2. 지원 이벤트

<table><thead><tr><th width="200">이벤트</th><th width="236">설명</th></tr></thead><tbody><tr><td><code>order_completed</code></td><td>주문 결제 완료 시 발생</td></tr></tbody></table>

***

### 3. 관리자에서 웹훅 등록

* 관리자 > 사이트 관리 > **웹훅** 메뉴로 이동
* **웹훅 등록** 클릭
* 아래 항목을 입력합니다.

<table><thead><tr><th width="202">항목</th><th width="374">설명</th></tr></thead><tbody><tr><td>웹훅 이름</td><td>식별용 이름 (예: “주문 연동”)</td></tr><tr><td>이벤트</td><td><code>order_completed</code> 선택</td></tr><tr><td>수신 URL</td><td>웹훅을 수신할 엔드포인트 URL (HTTPS 권장)</td></tr><tr><td>상태</td><td>활성 / 비활성</td></tr></tbody></table>

1. 등록 후 **서명키(Secret Key)** 가 자동 생성됩니다. 수정 화면에서 확인할 수 있습니다.
2. **API 암호화 키(AES Key)** 는 사이트 설정에서 별도 관리됩니다. 관리자에게 문의하여 발급받으세요.

> **필요한 키(2개)**\
> \
> **Secret Key** — 웹훅별 자동 생성, 서명 검증용
>
> **AES Key** — 사이트별 설정, 개인정보 복호화용<br>

***

### 4. 웹훅 수신 사양

#### HTTP 요청

<table><thead><tr><th width="204">항목</th><th width="318">값</th></tr></thead><tbody><tr><td>Method</td><td><code>POST</code></td></tr><tr><td>Content-Type</td><td><code>application/json; charset=utf-8</code></td></tr><tr><td>User-Agent</td><td><code>LMS-Webhook/1.0</code></td></tr><tr><td>X-Webhook-Signature</td><td>HMAC-SHA256 서명 (hex)</td></tr></tbody></table>

#### 응답

<table><thead><tr><th width="202">HTTP 상태</th><th width="240">처리</th></tr></thead><tbody><tr><td>200~299</td><td>성공</td></tr><tr><td>그 외</td><td>실패 → 자동 재시도</td></tr></tbody></table>

수신 서버는 200 OK를 반환해야 합니다. 응답 본문은 자유입니다.<br>

#### 재시도 정책

실패 시 지수 백오프(exponential backoff) 방식으로 최대 6회 재시도합니다. (초기 발송 포함 총 7회 시도)

<table><thead><tr><th width="210">재시도</th><th width="204" valign="middle">대기 시간</th></tr></thead><tbody><tr><td>1차</td><td valign="middle">1분</td></tr><tr><td>2차</td><td valign="middle">5분</td></tr><tr><td>3차</td><td valign="middle">30분</td></tr><tr><td>4차</td><td valign="middle">2시간</td></tr><tr><td>5차</td><td valign="middle">12시간</td></tr><tr><td>6차</td><td valign="middle">24시간</td></tr></tbody></table>

6회 재시도 후에도 실패하면 해당 건은 **실패 확정** 처리됩니다.

***

### 5. 서명 검증

요청의 위변조 여부를 확인하려면 `X-Webhook-Signature` 헤더를 검증합니다.

#### 검증 방법

1. 요청 본문(body)을 그대로 가져옵니다 (raw string).
2. 등록 시 발급된 **Secret Key** 를 키로 사용하여 HMAC-SHA256 해시를 생성합니다.
3. 생성된 해시(hex)와 `X-Webhook-Signature` 헤더 값을 비교합니다.

#### 예제 코드

**Java**

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public static String hmacSha256(String data, String key) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
    byte[] hash = mac.doFinal(data.getBytes("UTF-8"));
    StringBuilder sb = new StringBuilder();
    for (byte b : hash) sb.append(String.format("%02x", b));
    return sb.toString();
}

// 검증
String body = /* request body */;
String signature = request.getHeader("X-Webhook-Signature");
String expected = hmacSha256(body, SECRET_KEY);

if (!expected.equals(signature)) {
    // 서명 불일치 — 요청 거부
}
```

**Python**

```python
import hmac
import hashlib

def verify_signature(body: bytes, secret_key: str, signature: str) -> bool:
    expected = hmac.new(
        secret_key.encode('utf-8'),
        body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Flask 예시
@app.route('/webhook', methods=['POST'])
def webhook():
    body = request.get_data()
    signature = request.headers.get('X-Webhook-Signature', '')

    if not verify_signature(body, SECRET_KEY, signature):
        return 'Invalid signature', 403

    payload = request.get_json()
    # 처리 로직...
    return 'OK', 200
```

**PHP**

```php
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, $secretKey);

if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit('Invalid signature');
}

$payload = json_decode($body, true);
// 처리 로직...
http_response_code(200);
```

**Node.js**

```jsx
const crypto = require('crypto');

function verifySignature(body, secretKey, signature) {
    const expected = crypto
        .createHmac('sha256', secretKey)
        .update(body, 'utf8')
        .digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature)
    );
}

// Express 예시
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-webhook-signature'] || '';

    if (!verifySignature(req.body.toString(), SECRET_KEY, signature)) {
        return res.status(403).send('Invalid signature');
    }

    const payload = JSON.parse(req.body);
    // 처리 로직...
    res.sendStatus(200);
});
```

***

### 6. 페이로드 구조

#### order\_completed

```json
{
  "event": "order_completed",
  "user": {
    "user_id": 12345,
    "login_id": "AES암호화된값",
    "user_nm": "AES암호화된값",
    "email": "AES암호화된값",
    "mobile": "AES암호화된값",
    "email_yn": "Y",
    "sms_yn": "Y"
  },
  "order": {
    "order_id": 67890,
    "order_nm": "주문명",
    "price": 100000,
    "delivery_price": 0,
    "disc_price": 0,
    "disc_group_price": 0,
    "disc_level_price": 0,
    "coupon_price": 0,
    "pay_price": 100000,
    "tax_price": 9091,
    "taxfree_price": 0,
    "paymethod": "card",
    "pay_date": "20260417120000",
    "reg_date": "20260417115930",
    "ord_address": "서울시 강남구",
    "ord_new_address": "서울시 강남구 테헤란로 123",
    "ord_addr_dtl": "4층",
    "ord_phone": "02-1234-5678",
    "ord_mobile": "010-1234-5678",
    "ord_memo": "배송 메모"
  },
  "order_items": [
    {
      "order_item_id": 1001,
      "product_nm": "상품명",
      "product_type": "course",
      "product_id": 100,
      "option_id": 0,
      "option_nm": "",
      "course_id": 50,
      "renew_yn": "N",
      "renew_id": 0,
      "quantity": 1,
      "unit_price": 100000,
      "price": 100000,
      "disc_price": 0,
      "disc_group_price": 0,
      "disc_level_price": 0,
      "coupon_price": 0,
      "tax_price": 9091,
      "taxfree_price": 0,
      "coupon_user": {},
      "freepass_user": {}
    }
  ]
}
```

#### 암호화 필드

`user` 객체 내 아래 4개 필드는 **AES-128**로 암호화되어 전송됩니다:

<table><thead><tr><th width="157">필드</th><th width="180">평문 예시</th><th>전송 값</th></tr></thead><tbody><tr><td><code>login_id</code></td><td><code>hong123</code></td><td>AES-128 암호화 문자열 (Base64)</td></tr><tr><td><code>user_nm</code></td><td><code>홍길동</code></td><td>AES-128 암호화 문자열 (Base64)</td></tr><tr><td><code>email</code></td><td><code>hong@example.com</code></td><td>AES-128 암호화 문자열 (Base64)</td></tr><tr><td><code>mobile</code></td><td><code>01012345678</code></td><td>AES-128 암호화 문자열 (Base64)</td></tr></tbody></table>

나머지 필드(주문 정보, 주문 항목 등)는 **평문**으로 전송됩니다.

#### 금액 필드 설명

<table><thead><tr><th width="206">필드</th><th width="200">설명</th></tr></thead><tbody><tr><td><code>price</code></td><td>상품 정가 합계</td></tr><tr><td><code>delivery_price</code></td><td>배송비</td></tr><tr><td><code>disc_price</code></td><td>즉시할인 금액</td></tr><tr><td><code>disc_group_price</code></td><td>그룹할인 금액</td></tr><tr><td><code>disc_level_price</code></td><td>등급할인 금액</td></tr><tr><td><code>coupon_price</code></td><td>쿠폰할인 금액</td></tr><tr><td><code>pay_price</code></td><td>실결제 금액</td></tr><tr><td><code>tax_price</code></td><td>과세 금액</td></tr><tr><td><code>taxfree_price</code></td><td>면세 금액</td></tr></tbody></table>

#### 쿠폰/프리패스 사용 시

주문 항목에 쿠폰이 적용된 경우:

```json
"coupon_user": {
    "coupon_user_id": 500,
    "coupon_no": "COUP-20260417-001",
    "coupon_id": 10,
    "coupon_nm": "신규회원 10% 할인",
    "coupon_cd": "percent"
}
```

프리패스가 적용된 경우:

```json
"freepass_user": {
    "freepass_user_id": 300,
    "freepass_id": 5,
    "freepass_nm": "프리미엄 프리패스"
}
```

미사용 시 빈 객체(`{}`)로 전송됩니다.

### 7. 전체 필드 목록

#### user

<table><thead><tr><th width="172">필드</th><th width="183">명칭</th><th>비고</th></tr></thead><tbody><tr><td><code>user_id</code></td><td>고유값</td><td></td></tr><tr><td><code>login_id</code></td><td>로그인아이디</td><td>AES 암호화 전송</td></tr><tr><td><code>user_nm</code></td><td>회원명</td><td>AES 암호화 전송</td></tr><tr><td><code>email</code></td><td>이메일</td><td>AES 암호화 전송</td></tr><tr><td><code>mobile</code></td><td>휴대전화</td><td>AES 암호화 전송</td></tr><tr><td><code>email_yn</code></td><td>이메일수신동의여부</td><td>Y/N</td></tr><tr><td><code>sms_yn</code></td><td>SMS수신동의여부</td><td>Y/N</td></tr></tbody></table>

#### order

| 필드                 | 명칭      | 비고             |
| ------------------ | ------- | -------------- |
| `order_id`         | 고유값     |                |
| `order_nm`         | 주문명     |                |
| `price`            | 주문총액    |                |
| `delivery_price`   | 배송비     |                |
| `disc_price`       | 총할인금액   |                |
| `disc_group_price` | 그룹할인금액  |                |
| `disc_level_price` | 등급할인금액  |                |
| `coupon_price`     | 총할인금액   | 쿠폰 할인 합계       |
| `pay_price`        | 실결제금액   |                |
| `tax_price`        | 부가세액    |                |
| `taxfree_price`    | 면세금액    |                |
| `paymethod`        | 결제방법    | 예: card        |
| `pay_date`         | 실결제일    | yyyyMMddHHmmss |
| `reg_date`         | 등록일     | yyyyMMddHHmmss |
| `ord_address`      | 배송지 구주소 |                |
| `ord_new_address`  | 배송지 신주소 |                |
| `ord_addr_dtl`     | 상세주소    |                |
| `ord_phone`        | 주문자연락처  |                |
| `ord_mobile`       | 주문자휴대전화 |                |
| `ord_memo`         | 주문자요청사항 |                |

#### order\_items\[] (TB\_ORDER\_ITEM — 주문항목관리)

| 필드                 | 명칭 (DB 코멘트) | 비고        |
| ------------------ | ----------- | --------- |
| `order_item_id`    | 고유값         |           |
| `product_nm`       | 상품명         |           |
| `product_type`     | 상품구분        | 예: course |
| `product_id`       | 상품아이디       |           |
| `option_id`        | 옵션ID        |           |
| `option_nm`        | 옵션명         |           |
| `course_id`        | 과정아이디(그룹핑)  |           |
| `renew_yn`         | 연장상품여부      | Y/N       |
| `renew_id`         | 연장대상아이디     |           |
| `quantity`         | 수량          |           |
| `unit_price`       | 단위가격        |           |
| `price`            | 주문금액        |           |
| `disc_price`       | 할인금액        |           |
| `disc_group_price` | 그룹할인금액      |           |
| `disc_level_price` | 등급할인금액      |           |
| `coupon_price`     | 할인금액        | 쿠폰 할인     |
| `tax_price`        | 부가세액        |           |
| `taxfree_price`    | 면세금액        |           |
| `coupon_user`      | 쿠폰사용자 정보    |           |
| `freepass_user`    | 프리패스사용자 정보  |           |

***

### 8. 개인정보 복호화 (AES-128)

암호화된 필드를 복호화하려면 관리자에게서 발급받은 **AES Key**를 사용합니다.

#### 암호화 방식

<table><thead><tr><th width="139">항목</th><th width="194">값</th></tr></thead><tbody><tr><td>알고리즘</td><td>AES</td></tr><tr><td>모드</td><td>ECB</td></tr><tr><td>패딩</td><td>PKCS5Padding</td></tr><tr><td>키 길이</td><td>16바이트 (128비트)</td></tr><tr><td>인코딩</td><td>Base64</td></tr></tbody></table>

#### 복호화 절차

1. 암호화된 문자열을 **Base64 디코딩**합니다.
2. 발급받은 AES Key (16바이트)를 키로 사용합니다.
3. **AES/ECB/PKCS5Padding** 으로 복호화합니다.
4. 결과를 UTF-8 문자열로 변환합니다.<br>

#### 예제 코드

**Java**

```java
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public static String decrypt(String encrypted, String aesKey) throws Exception {
    byte[] key = aesKey.getBytes("UTF-8");
    SecretKeySpec spec = new SecretKeySpec(key, "AES");
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, spec);
    byte[] decoded = Base64.getDecoder().decode(encrypted);
    return new String(cipher.doFinal(decoded), "UTF-8");
}

// 사용
String loginId = decrypt(payload.getJSONObject("user").getString("login_id"), AES_KEY);
```

**Python**

```python
import base64
from Crypto.Cipher import AES

def unpad(data):
    pad_len = data[-1]
    return data[:-pad_len]

def decrypt(encrypted: str, aes_key: str) -> str:
    key = aes_key.encode('utf-8')
    cipher = AES.new(key, AES.MODE_ECB)
    decoded = base64.b64decode(encrypted)
    decrypted = cipher.decrypt(decoded)
    return unpad(decrypted).decode('utf-8')

# 사용 (pip install pycryptodome)
login_id = decrypt(payload['user']['login_id'], AES_KEY)
```

**PHP**

```php
function webhookDecrypt(string $encrypted, string $aesKey): string {
    $key = $aesKey;
    $decoded = base64_decode($encrypted);
    return openssl_decrypt($decoded, 'AES-128-ECB', $key, OPENSSL_RAW_DATA);
}

// 사용
$loginId = webhookDecrypt($payload['user']['login_id'], $aesKey);
```

**Node.js**

```jsx
const crypto = require('crypto');

function decrypt(encrypted, aesKey) {
    const key = Buffer.from(aesKey, 'utf8');
    const decoded = Buffer.from(encrypted, 'base64');
    const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
    let decrypted = decipher.update(decoded);
    decrypted = Buffer.concat([decrypted, decipher.final()]);
    return decrypted.toString('utf8');
}

// 사용
const loginId = decrypt(payload.user.login_id, AES_KEY);
```

***

### 9. 전체 수신 처리 예제 (Node.js)

```jsx
const express = require('express');
const crypto = require('crypto');
const app = express();

const SECRET_KEY = 'your-webhook-secret-key';
const AES_KEY = 'your-aes-key-from-admin';

// raw body 유지 (서명 검증용)
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const body = req.body.toString('utf8');
    const signature = req.headers['x-webhook-signature'] || '';

    // 1. 서명 검증
    const expected = crypto
        .createHmac('sha256', SECRET_KEY)
        .update(body, 'utf8')
        .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
        console.error('서명 검증 실패');
        return res.status(403).send('Invalid signature');
    }

    // 2. JSON 파싱
    const payload = JSON.parse(body);
    console.log('이벤트:', payload.event);

    // 3. 개인정보 복호화
    const user = payload.user;
    const key = Buffer.from(AES_KEY, 'utf8');

    function decrypt(encrypted) {
        const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
        let dec = decipher.update(Buffer.from(encrypted, 'base64'));
        dec = Buffer.concat([dec, decipher.final()]);
        return dec.toString('utf8');
    }

    console.log('사용자 ID:', user.user_id);
    console.log('로그인 ID:', decrypt(user.login_id));
    console.log('이름:', decrypt(user.user_nm));
    console.log('이메일:', decrypt(user.email));
    console.log('휴대폰:', decrypt(user.mobile));

    // 4. 주문 정보
    const order = payload.order;
    console.log('주문번호:', order.order_id);
    console.log('결제금액:', order.pay_price);
    console.log('결제수단:', order.paymethod);

    // 5. 주문 항목
    payload.order_items.forEach(item => {
        console.log(`-${item.product_nm} x${item.quantity} =${item.price}원`);
    });

    // 200 OK 반환 (필수)
    res.sendStatus(200);
});

app.listen(3000, () => console.log('Webhook receiver on port 3000'));
```

***

### 10. 주의사항

* **반드시 200\~299 응답**을 반환하세요. 그 외 응답은 실패로 간주되어 재시도됩니다.
* **서명 검증 시 타이밍 공격 방지**를 위해 `timingSafeEqual` / `hash_equals` 등의 상수 시간 비교 함수를 사용하세요.
* **AES Key와 Secret Key는 안전하게 보관**하고, 코드에 하드코딩하지 마세요. 환경 변수 등을 활용하세요.
* **수신 처리는 10초 이내**로 완료하세요. 연결 타임아웃 5초, 읽기 타임아웃 10초가 설정되어 있어 응답이 늦으면 실패로 처리됩니다. 오래 걸리는 작업은 큐에 넣고 즉시 200을 반환하세요.
* **중복 발송 방지**: 동일 주문에 대한 웹훅은 시스템에서 1회만 등록됩니다. 단, 실패 재시도 시 동일 데이터가 재전송되므로, `order_id` 기준으로 멱등성(idempotency)을 보장하면 더 안전합니다.
* 날짜 형식은 `yyyyMMddHHmmss` (예: `20260417120000`)입니다.
* **응답 본문**은 최대 2,000자까지 로그에 기록됩니다.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://malgn.gitbook.io/cloud/api/order-info-webhook.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
