# BTSCON Mobile Coupon API Contract

- 규약 버전: `BTSCON-COUPON-API/1`
- 문서 기준일: `2026-10-24`
- 운영 Base URL: `https://api.btscon.co.kr/api/reseller`
- 적용 대상: BTSCON과 계약하고 API Key를 발급받은 모든 외부 연동 업체

## 2026-10-24 변경 사항

주문 결과 조회에 쿠폰별 실제 사용 만료일을 추가했다. 기존 API 경로·인증·규약 버전은 유지한다. 기존 발급 쿠폰도 원래 주문번호로 조회 가능하다. 상품 공통 유효일수로 계산한 날짜를 대신 제공하지 않는다.

## 1. 제공 기능

이 문서는 특정 업체 전용이 아닌 BTSCON 모바일쿠폰 공용 API 규약이다.

- 제공: 연결 확인, 배정 상품 목록, 상품 상세, 쿠폰 주문, 주문 결과 조회, 쿠폰 취소
- 상품 노출: BTSCON 관리자가 연동 계정에 등록하고 활성화한 상품만 조회됨
- 테스트 상품: BTSCON 관리자가 공용 테스트 목록에 등록하고 활성화한 상품만 조회됨
- 비공개 정보: 내부 공급 코드, 내부 처리 경로, 외부 원본 이미지 주소는 응답하지 않음

운영과 테스트는 모두 동일한 `https://api.btscon.co.kr/api/reseller` 경로를 사용하고 발급된 키 종류로 모드를 구분한다. 구형 내부 경로인 `/api/test/reseller/*`와 `/test/reseller/*`는 이 공용 계약의 대상이 아니므로 신규 연동에서 사용하지 않는다.

## 2. 발급 정보

BTSCON 관리자가 연동 계정에 다음 값을 발급한다.

```env
BTSCON_COUPON_API_BASE_URL=https://api.btscon.co.kr/api/reseller
BTSCON_COUPON_API_KEY=...
BTSCON_COUPON_HMAC_SECRET=...
```

API Key와 HMAC Secret은 서로 다른 비밀값으로 저장해야 한다. 운영자가 등록한 발신 IP/CIDR에서만 호출할 수 있다.

- API Key와 HMAC Secret은 브라우저 JavaScript, 모바일 앱, 공개 저장소에 포함하지 않는다.
- 서버 환경변수 또는 비밀 저장소에서만 읽는다.
- 로그에서 인증 헤더, 휴대폰, 쿠폰번호, PIN, 바코드를 마스킹한다.
- 유출이 의심되면 해당 키를 즉시 폐기하고 새 키를 발급한다.
- 문서의 모든 인증값과 회원·쿠폰 값은 형식 설명을 위한 가상 예시다.

## 3. 인증

모든 요청은 HTTPS와 다음 헤더를 사용한다.

```http
x-api-key: {BTSCON_COUPON_API_KEY}
x-bts-ts: {UTC Unix seconds}
x-bts-nonce: {request unique nonce}
x-bts-sign: {HMAC-SHA256 lowercase hex}
Content-Type: application/json
```

`x-bts-nonce`는 16~128자의 요청 고유값이며 재사용할 수 없다. Timestamp 허용 오차는 BTSCON 서버 시각 기준 `±300초`다.

### 3.1 서명 원문

아래 6개 값을 LF(`\n`)로 연결한다. 마지막 LF는 추가하지 않는다.

```text
{timestamp}
{nonce}
{UPPERCASE_HTTP_METHOD}
{request_path}
{raw_query_string}
{sha256_hex(raw_body_bytes)}
```

- `request_path`: 호스트를 제외한 경로
- `raw_query_string`: `?`를 제외한 실제 전송 query, 없으면 빈 문자열
- `raw_body_bytes`: 실제 전송할 body 바이트, GET은 빈 바이트
- JSON을 서명한 뒤 다시 직렬화하면 안 됨
- 서명은 lowercase hex이며 `sha256={hex}`도 허용
- 요청 body 최대 크기는 64KiB

### 3.2 Python 서명 예시

```python
import hashlib
import hmac
import json
import secrets
import time


def sign_btscon(method, path, raw_query, payload, secret):
    body = b"" if payload is None else json.dumps(
        payload,
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    canonical = "\n".join([
        timestamp,
        nonce,
        method.upper(),
        path,
        raw_query,
        hashlib.sha256(body).hexdigest(),
    ])
    signature = hmac.new(
        secret.encode("utf-8"),
        canonical.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return body, {
        "x-bts-ts": timestamp,
        "x-bts-nonce": nonce,
        "x-bts-sign": signature,
    }
```

## 4. API 경로

| 메서드 | 경로 | 설명 |
| --- | --- | --- |
| `GET` | `/api/reseller/ping` | 인증 및 연결 확인 |
| `GET` | `/api/reseller/catalog?limit=100&offset=0` | 활성 상품 목록 |
| `GET` | `/api/reseller/products/{goodsId}` | 상품 상세 |
| `POST` | `/api/reseller/orders` | 쿠폰 발행 및 구매자 문자 발송 접수 |
| `GET` | `/api/reseller/orders/{orderId}` | 쿠폰 발행 및 문자 발송 결과 조회 |
| `POST` | `/api/reseller/orders/{orderId}/cancel` | 발행 쿠폰 취소 |

## 5. 연결 확인

### `GET /ping`

```json
{
  "status": "ok",
  "authenticated": true,
  "test": false
}
```

## 6. 상품 목록

### `GET /catalog?limit=100&offset=0`

- `limit`: 1~200, 기본값 100
- `offset`: 0 이상, 기본값 0
- 운영 키는 해당 계정에 배정된 활성 상품만 반환함
- 테스트 키는 BTSCON 관리자가 테스트 목록에 등록하고 활성화한 상품만 반환함

```json
{
  "items": [
    {
      "goodsId": "BTS-T-7F4A9C2E81D643B5A0C7E219",
      "goodsName": "모바일쿠폰 1만원권",
      "brandName": "테스트 브랜드",
      "brandImageUrl": "https://api.btscon.co.kr/public/reseller/products/BTS-T-7F4A9C2E81D643B5A0C7E219/brand-image",
      "categoryName": "모바일쿠폰",
      "description": "테스트 주문용 상품입니다.",
      "imageUrl": "https://api.btscon.co.kr/public/reseller/products/BTS-T-7F4A9C2E81D643B5A0C7E219/image",
      "validDate": "20271231",
      "validDays": "365",
      "cancelable": true,
      "refundable": true,
      "extendable": false,
      "sellPrice": 9000,
      "consumerPrice": 10000,
      "sellEndDate": null,
      "status": "active"
    }
  ],
  "limit": 100,
  "offset": 0
}
```

`imageUrl`과 `brandImageUrl`은 불투명 BTSCON 상품 코드로 만든 이미지 프록시 주소만 반환한다. 실제 저장 경로, 공급사 원본 URL, 내부 상품·브랜드 코드는 노출하지 않으며 사용할 이미지가 없으면 빈 문자열이다. 운영 키와 테스트 키는 같은 필드 구조를 사용한다. 테스트 상품의 원본 이미지가 없거나 만료된 경우에는 실제 쿠폰으로 오인하지 않도록 표시된 BTSCON 샘플 이미지를 반환한다.

## 7. 상품 상세

### `GET /products/{goodsId}`

```json
{
  "item": {
    "goodsId": "BTS-T-7F4A9C2E81D643B5A0C7E219",
    "goodsName": "모바일쿠폰 1만원권",
    "brandName": "테스트 브랜드",
    "brandImageUrl": "https://api.btscon.co.kr/public/reseller/products/BTS-T-7F4A9C2E81D643B5A0C7E219/brand-image",
    "categoryName": "모바일쿠폰",
    "description": "테스트 주문용 상품입니다.",
    "imageUrl": "https://api.btscon.co.kr/public/reseller/products/BTS-T-7F4A9C2E81D643B5A0C7E219/image",
    "validDate": "20271231",
    "validDays": "365",
    "cancelable": true,
    "refundable": true,
    "extendable": false,
    "sellPrice": 9000,
    "consumerPrice": 10000,
    "sellEndDate": null,
    "status": "active"
  },
  "test": false
}
```

없는 상품 또는 이 계정에 활성화되지 않은 상품은 `404 product_not_found`다.

## 8. 쿠폰 발행 및 구매자 문자 발송

### `POST /orders`

이 API를 호출하면 BTSCON이 쿠폰을 발행하고 `receiver`에 입력한 구매자 휴대폰 번호로 모바일쿠폰 문자를 발송한다. 호출 업체가 별도로 문자를 발송할 필요는 없다.

```json
{
  "orderId": "SHOP-ORDER-20260909-0001",
  "goodsId": "BTS-T-7F4A9C2E81D643B5A0C7E219",
  "receiver": "01012345678",
  "sender": "0212345678",
  "title": "모바일쿠폰",
  "msg": "구매하신 모바일쿠폰입니다."
}
```

필수값은 `goodsId`, `receiver`, `sender`, `title`, `msg`다. `orderId`를 입력할 경우 호출 업체 내부에서 영구적으로 유일해야 한다.

- `receiver`: 쿠폰 문자를 받을 구매자 휴대폰 번호, 숫자 10~11자리
- `sender`: 사전 승인된 발신번호, 숫자 8~16자리
- `title`: 구매자에게 보낼 문자 제목, 1~100자
- `msg`: 구매자에게 보낼 문자 내용, 1~2,000자
- `customImageUrl`: 사용할 경우 HTTPS 주소, 최대 500자

접수 응답:

```json
{
  "orderId": "SHOP-ORDER-20260909-0001",
  "clientOrderId": "SHOP-ORDER-20260909-0001",
  "status": "queued"
}
```

`queued`는 쿠폰 발행과 문자 발송 작업이 접수된 상태이며 최종 성공을 뜻하지 않는다. `GET /orders/{orderId}`로 최종 상태를 확인한다.

운영 주문은 발급 전에 공급대금을 확보하며 잔액이 부족하면 `409 insufficient_balance`로 거부한다. 같은 `orderId`와 같은 내용의 재요청은 기존 주문을 반환한다. 내용을 변경하면 `409 idempotency_conflict`로 거부하며, 실패·취소된 주문번호도 새 발급에 재사용할 수 없다.

개별 sandbox 키의 실제 발급은 `403 sandbox_issuance_disabled`로 차단한다. 공용 테스트 키는 기존 테스트 전용 주문 경로로 처리하며 실제 발급·차감·문자 발송을 하지 않는다.

테스트 키로 주문하면 형식과 상태 전이만 검증하며 구매자 휴대폰으로 실제 문자를 발송하지 않는다.

## 9. 주문 결과 조회

### `GET /orders/{orderId}`

주문 조회의 `expiresAt`은 발급된 쿠폰별 실제 사용 만료시각(ISO 8601, `+09:00`), `expiresOn`은 한국 기준 만료일입니다. `expiryTimezone=Asia/Seoul`, `expiryInclusive=true`이므로 해당 시각까지 사용 가능합니다. `expiryBasis=coupon_end_datetime`은 쿠폰 만료시각 원본, `coupon_end_date`는 쿠폰 만료일 원본의 당일 23:59:59 기준입니다. 기존 발급 쿠폰도 같은 주문번호로 조회할 수 있습니다. 쿠폰별 만료정보가 없으면 날짜는 `null`, 기준은 `unavailable`이며 상품 공통 유효일수로 추정하지 않습니다. 만료일은 사용·취소 여부와 별개이므로 주문 상태도 확인하세요. 테스트 주문에는 실제 만료일이 없습니다.

```json
{
  "orderId": "SHOP-ORDER-20260909-0001",
  "goodsId": "BTS-T-7F4A9C2E81D643B5A0C7E219",
  "status": "sent",
  "tradeType": "ISSUE",
  "tradeCode": "02",
  "tradeDate": "20260909153000",
  "expiresAt": "2026-12-23T23:59:59+09:00",
  "expiresOn": "2026-12-23",
  "expiryTimezone": "Asia/Seoul",
  "expiryInclusive": true,
  "expiryBasis": "coupon_end_datetime",

  "sellPrice": 9000
}
```

| 필드 | 형식 | 기준 |
| --- | --- | --- |
| `expiresAt` | ISO 8601 문자열 또는 null | 발급된 쿠폰별 만료시각. `+09:00` 포함 |
| `expiresOn` | YYYY-MM-DD 또는 null | 한국시간 기준 만료일 |
| `expiryTimezone` | 문자열 | `Asia/Seoul` |
| `expiryInclusive` | boolean | `true`: 명시된 만료시각까지 포함 |
| `expiryBasis` | 문자열 | `coupon_end_datetime`, `coupon_end_date`, `unavailable` |

고객 화면·이미지에서는 `expiresAt`을 한국시간으로 표시한다. 기존 주문번호로 다시 조회해 저장된 쿠폰의 만료정보를 보완할 수 있다. 날짜가 null이면 사용기한 확인 중으로 처리하며 무기한으로 해석하지 않는다.

`sent`는 쿠폰 발행 및 구매자 문자 발송 처리가 성공한 상태다. 네트워크 오류나 주문 응답 유실 시 새 주문번호로 재발행하지 말고 원래 `orderId`로 조회한다.

쿠폰번호, PIN, 바코드는 `receiver`의 구매자 문자로만 전달하며 공용 API 응답에는 포함하지 않는다. 내부 발행 경로와 처리 방식도 이 공용 규약의 범위가 아니다.

## 10. 쿠폰 취소

### `POST /orders/{orderId}/cancel`

```json
{
  "cancelRequestId": "SHOP-CANCEL-20260909-0001",
  "reason": "고객 주문 취소"
}
```

- `reason`: 3자 이상
- `cancelRequestId`: 호출 업체 내부 취소 요청 식별자
- 동일 요청 재호출 시 이미 처리된 결과를 반환함
- 실제 쿠폰 취소가 확인된 뒤에만 정산을 복원함

```json
{
  "orderId": "SHOP-ORDER-20260909-0001",
  "status": "cancelled",
  "tradeCode": "03",
  "changed": true,
  "cancelRequestId": "SHOP-CANCEL-20260909-0001"
}
```

## 11. 상태와 재시도

| 상태 | 의미 |
| --- | --- |
| `requested` | 주문 생성 |
| `queued` | 쿠폰 발행 및 문자 발송 작업 접수 |
| `issued` | 쿠폰 발행 완료, 문자 발송 준비 중 |
| `delivery_pending` | 문자 발송 요청 완료, 결과 확인 중 |
| `sent` | 쿠폰 발행 및 문자 발송 처리 성공 |
| `delivery_failed` | 쿠폰은 발행됐으나 문자 발송 실패 |
| `failed` | 쿠폰 발행 실패 |
| `issuance_unknown` | 발행 여부 미확정. 자동 재발급·환불 금지, 기존 주문 대사 필요 |
| `cancelled` | 취소 완료 |

- HTTP `408`, `429`, `500`, `502`, `503`, `504`: 같은 주문번호와 같은 요청 내용으로 지수 백오프 재시도
- HTTP `400`, `401`, `403`, `409`: 요청값 또는 운영 설정을 수정한 뒤 처리
- 상태가 불명확하면 자동 재발행하거나 자동 환불하지 말고 주문 조회 후 처리
- `issuance_unknown`이면 주문번호를 유지하고 상태 확인·수동 대사를 진행한다. 발행 미확정만으로 공급대금이나 고객 포인트를 반환하지 않는다.

## 12. 오류

기본 형식:

```json
{
  "error": "error_code"
}
```

| HTTP | 오류 | 의미 |
| --- | --- | --- |
| 400 | `invalid_json` | JSON 파싱 실패 |
| 400 | `invalid_goods_id` | 상품 ID 형식 오류 |
| 400 | `invalid_order_id` | 주문번호 형식 오류 |
| 400 | `invalid_receiver` | 수신번호 형식 오류 |
| 400 | `invalid_sender` | 발신번호 형식 오류 |
| 400 | `invalid_title` | 제목 길이 오류 |
| 400 | `invalid_message` | 메시지 길이 오류 |
| 400 | `invalid_custom_image_url` | 이미지 URL 형식 오류 |
| 400 | `missing_fields` | 주문 필수값 누락 |
| 400 | `missing_reason` | 취소 사유 누락 |
| 401 | `missing_api_key` | API Key 누락 |
| 401 | `invalid_api_key` | API Key 오류 |
| 401 | `invalid_hmac_signature` | HMAC 서명 오류 |
| 401 | `hmac_nonce_reused` | nonce 재사용 |
| 403 | `ip_not_allowed` | 허용 발신 IP 불일치 |
| 404 | `product_not_found` | 상품 없음 또는 미배정 |
| 404 | `order_not_found` | 주문 없음 |
| 409 | `duplicate_order_id` | 주문번호 중복 |
| 409 | `cancel_in_progress` | 같은 주문 취소 처리 중 |
| 413 | `request_too_large` | 요청 body가 64KiB 초과 |

## 13. 운영 전 확인표

- [ ] 운영 발신 공인 IP/CIDR 등록
- [ ] API Key와 HMAC Secret 분리 보관
- [ ] `/ping` 성공 확인
- [ ] 관리자가 등록한 테스트 상품의 목록·상세 조회 확인
- [ ] 동일 body 바이트 기준 HMAC 검증
- [ ] 동일 주문번호 재시도 및 주문 조회 확인
- [ ] 발행 성공·실패 상태 처리 확인
- [ ] 취소 성공과 정산 복원 확인
- [ ] 응답 및 로그에 내부 코드와 외부 원본 이미지 주소가 없는지 확인
